From 4622848a19fb9ac54a5ebe420973c2bf173a6575 Mon Sep 17 00:00:00 2001 From: Kristina <7614497+khjrtbrg@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:19:20 +0200 Subject: [PATCH] [web] optimize process notifications and add monitoring metrics (#34699) GitOrigin-RevId: 411355cbdbb25d871b9cfb2afbb0ad235041337f --- .editorconfig | 25 - develop/.gitignore | 3 - develop/README.md | 76 - develop/bin/build | 3 - develop/bin/dev | 3 - develop/bin/down | 3 - develop/bin/logs | 9 - develop/bin/shell | 3 - develop/bin/up | 3 - develop/compiles/.gitkeep | 0 develop/dev.env | 24 - develop/docker-compose.dev.yml | 128 -- develop/docker-compose.yml | 175 -- develop/webpack.config.dev-env.js | 23 - dockerfiles/cypress/Dockerfile | 18 - libraries/access-token-encryptor/.nvmrc | 1 - libraries/access-token-encryptor/Makefile | 59 - .../access-token-encryptor/buildscript.txt | 9 - libraries/fetch-utils/.nvmrc | 1 - libraries/fetch-utils/Makefile | 59 - libraries/fetch-utils/buildscript.txt | 9 - libraries/logger/.nvmrc | 1 - libraries/logger/Makefile | 59 - libraries/logger/buildscript.txt | 9 - libraries/metrics/.nvmrc | 1 - libraries/metrics/Makefile | 59 - libraries/metrics/buildscript.txt | 9 - libraries/mongo-utils/.nvmrc | 1 - libraries/mongo-utils/Makefile | 59 - libraries/mongo-utils/buildscript.txt | 10 - libraries/o-error/.nvmrc | 1 - libraries/o-error/Makefile | 59 - libraries/o-error/buildscript.txt | 9 - libraries/object-persistor/.nvmrc | 1 - libraries/object-persistor/Makefile | 59 - libraries/object-persistor/buildscript.txt | 10 - libraries/overleaf-editor-core/.nvmrc | 1 - libraries/overleaf-editor-core/Makefile | 59 - .../overleaf-editor-core/buildscript.txt | 9 - libraries/promise-utils/.nvmrc | 1 - libraries/promise-utils/Makefile | 59 - libraries/promise-utils/buildscript.txt | 9 - libraries/ranges-tracker/.nvmrc | 1 - libraries/ranges-tracker/Makefile | 59 - libraries/ranges-tracker/buildscript.txt | 9 - libraries/redis-wrapper/.nvmrc | 1 - libraries/redis-wrapper/Makefile | 59 - libraries/redis-wrapper/buildscript.txt | 9 - libraries/settings/.nvmrc | 1 - libraries/settings/Makefile | 59 - libraries/settings/buildscript.txt | 9 - libraries/stream-utils/.nvmrc | 1 - libraries/stream-utils/Makefile | 59 - libraries/stream-utils/buildscript.txt | 9 - libraries/validation-tools/.nvmrc | 1 - libraries/validation-tools/Makefile | 59 - libraries/validation-tools/buildscript.txt | 10 - server-ce/eslint.config.mjs | 33 - server-ce/test/.gitignore | 2 - server-ce/test/Dockerfile | 15 - server-ce/test/Makefile | 126 -- server-ce/test/accounts.spec.ts | 34 - server-ce/test/admin.spec.ts | 402 ---- .../test/create-and-compile-project.spec.ts | 144 -- server-ce/test/customization.spec.ts | 44 - server-ce/test/cypress.config.ts | 67 - server-ce/test/cypress/.gitignore | 3 - .../test/cypress/cypress-multi-reporters.cjs | 10 - server-ce/test/cypress/support/e2e.ts | 12 - server-ce/test/docker-compose.native.yml | 48 - server-ce/test/docker-compose.yml | 158 -- server-ce/test/editor.spec.ts | 451 ----- server-ce/test/external-auth.spec.ts | 73 - server-ce/test/filestore-migration.spec.ts | 433 ----- server-ce/test/git-bridge.spec.ts | 458 ----- server-ce/test/graceful-shutdown.spec.ts | 109 -- .../helpers/beforeWithReRunOnTestRetry.ts | 8 - server-ce/test/helpers/compile.ts | 72 - server-ce/test/helpers/config.ts | 114 -- server-ce/test/helpers/email.ts | 44 - server-ce/test/helpers/hostAdminClient.ts | 136 -- server-ce/test/helpers/login.ts | 113 -- server-ce/test/helpers/project.ts | 402 ---- server-ce/test/helpers/read-file.ts | 61 - .../helpers/waitUntilScrollingFinished.ts | 39 - server-ce/test/history.spec.ts | 164 -- server-ce/test/host-admin.js | 494 ----- server-ce/test/learn-wiki.spec.ts | 123 -- server-ce/test/package.json | 31 - server-ce/test/project-list.spec.ts | 125 -- server-ce/test/project-sharing.spec.ts | 540 ------ server-ce/test/sandboxed-compiles.spec.ts | 351 ---- server-ce/test/templates.spec.ts | 281 --- server-ce/test/tsconfig.json | 18 - server-ce/test/upgrading.spec.ts | 191 -- services/chat/.nvmrc | 1 - services/chat/Dockerfile | 50 - services/chat/Makefile | 205 --- services/chat/buildscript.txt | 9 - services/chat/docker-compose.ci.yml | 64 - services/chat/docker-compose.yml | 79 - services/clsi/.nvmrc | 1 - services/clsi/.nycrc | 7 - services/clsi/Dockerfile | 68 - services/clsi/Makefile | 210 --- services/clsi/buildscript.txt | 12 - services/clsi/docker-compose.ci.yml | 70 - services/clsi/docker-compose.yml | 80 - services/docstore/.nvmrc | 1 - services/docstore/Dockerfile | 56 - services/docstore/Makefile | 208 --- services/docstore/buildscript.txt | 11 - services/docstore/docker-compose.ci.yml | 79 - services/docstore/docker-compose.yml | 93 - services/document-updater/.nvmrc | 1 - services/document-updater/Dockerfile | 56 - services/document-updater/Makefile | 208 --- services/document-updater/buildscript.txt | 9 - .../document-updater/docker-compose.ci.yml | 85 - services/document-updater/docker-compose.yml | 100 - services/filestore/.nvmrc | 1 - services/filestore/Dockerfile | 51 - services/filestore/Makefile | 205 --- services/filestore/buildscript.txt | 14 - services/filestore/docker-compose.ci.yml | 227 --- services/filestore/docker-compose.yml | 239 --- services/git-bridge/Dockerfile | 69 - services/git-bridge/Makefile | 93 - services/history-v1/.nvmrc | 1 - services/history-v1/Dockerfile | 65 - services/history-v1/Makefile | 210 --- services/history-v1/buildscript.txt | 10 - services/history-v1/docker-compose.ci.yml | 277 --- services/history-v1/docker-compose.yml | 292 --- services/notifications/.nvmrc | 1 - services/notifications/Dockerfile | 53 - services/notifications/Makefile | 206 --- services/notifications/buildscript.txt | 13 - services/notifications/docker-compose.ci.yml | 67 - services/notifications/docker-compose.yml | 81 - services/project-history/.nvmrc | 1 - services/project-history/Dockerfile | 58 - services/project-history/Makefile | 209 --- services/project-history/buildscript.txt | 9 - .../project-history/docker-compose.ci.yml | 85 - services/project-history/docker-compose.yml | 100 - services/real-time/.nvmrc | 1 - services/real-time/Dockerfile | 49 - services/real-time/Makefile | 205 --- services/real-time/buildscript.txt | 10 - services/real-time/docker-compose.ci.yml | 64 - services/real-time/docker-compose.yml | 76 - .../.github/prompts/await-migration.prompt.md | 389 ---- services/web/.nvmrc | 1 - services/web/.nycrc | 7 - services/web/.storybook/index.html.m4 | 78 - services/web/.storybook/main.ts | 159 -- services/web/.storybook/manager.css | 3 - services/web/.storybook/manager.ts | 15 - services/web/.storybook/preview.tsx | 195 -- .../web/.storybook/utils/figma-design-url.ts | 20 - .../web/.storybook/utils/with-split-tests.tsx | 37 - services/web/.stylelintrc.json | 23 - services/web/.vscode/settings.json | 7 - services/web/Dockerfile | 150 -- services/web/Dockerfile.frontend | 6 - services/web/Dockerfile.frontend.ci | 11 - services/web/Makefile | 802 -------- services/web/Makefile.module | 74 - services/web/bin/cdn_upload | 73 - services/web/bin/cdn_upload_batch | 47 - services/web/bin/check_extracted_translations | 24 - services/web/bin/compress_assets | 14 - services/web/bin/compress_batch_of_assets | 16 - services/web/bin/copy_external_pages | 6 - services/web/bin/lint_flag_res_send_usage | 48 - services/web/bin/lint_locales | 9 - services/web/bin/lint_pug_templates | 31 - services/web/bin/push-translations-changes.sh | 18 - services/web/bin/routes | 130 -- services/web/bin/run | 8 - services/web/bin/sentry_upload | 22 - services/web/bin/test_acceptance_run_dir | 12 - services/web/bin/test_unit_run_dir | 16 - services/web/buildscript.txt | 8 - ...iCertGlobalG2TLSRSASHA2562020CA1-1.crt.pem | 28 - services/web/data/.gitignore | 4 - services/web/data/coverage/.gitignore | 0 services/web/data/dumpFolder/.gitignore | 0 services/web/data/logs/.gitignore | 0 services/web/data/pdfs/.gitignore | 0 services/web/data/projectHistories/.gitignore | 0 services/web/data/reports/.gitignore | 0 services/web/data/uploads/.gitignore | 0 services/web/data/zippedProjects/.gitignore | 0 services/web/docker-compose.ci.yml | 180 -- services/web/docker-compose.common.env | 55 - services/web/docker-compose.cypress.yml | 18 - services/web/docker-compose.yml | 191 -- services/web/eslint.config.mjs | 988 ---------- .../web/frontend/stories/chat.stories.jsx | 58 - .../stories/clone-project-modal.stories.jsx | 61 - .../stories/contact-us-modal.stories.tsx | 88 - .../stories/decorators/ide-redesign.tsx | 7 - .../web/frontend/stories/decorators/scope.tsx | 199 -- .../stories/deprecated-browser.stories.tsx | 13 - .../frontend/stories/docs/cloud-builds.mdx | 23 - .../frontend/stories/docs/feature-flags.mdx | 41 - .../stories/docs/format-currency.stories.tsx | 44 - .../web/frontend/stories/docs/foundations.mdx | 256 --- .../stories/editor-switch.stories.jsx | 12 - .../editor/pdf-compile-button.stories.tsx | 54 - .../stories/editor/synctex-toasts.stories.tsx | 26 - .../frontend/stories/file-tree.stories.jsx | 180 -- .../stories/file-view/file-view.stories.jsx | 204 --- .../stories/fixtures/chat-messages.js | 34 - .../web/frontend/stories/fixtures/compile.js | 220 --- .../web/frontend/stories/fixtures/contacts.js | 41 - .../web/frontend/stories/fixtures/document.ts | 40 - .../stories/fixtures/file-tree-base.js | 44 - .../stories/fixtures/file-tree-limit.js | 52 - .../web/frontend/stories/fixtures/project.ts | 48 - .../stories/fixtures/storybook-example.pdf | Bin 248910 -> 0 bytes .../history/document-diff-viewer.stories.tsx | 157 -- .../history/history-version.stories.tsx | 94 - .../stories/history/label-version.stories.tsx | 77 - .../stories/history/toggle-switch.stories.tsx | 46 - .../frontend/stories/hooks/use-fetch-mock.tsx | 20 - .../web/frontend/stories/hooks/use-meta.tsx | 10 - .../web/frontend/stories/hooks/use-scope.tsx | 30 - .../stories/hotkeys-modal.stories.tsx | 30 - .../stories/loading/loading.stories.tsx | 45 - .../create-file-modal-decorator.jsx | 118 -- .../create-file-modal-footer.stories.jsx | 62 - .../create-file/create-file-modal.stories.jsx | 89 - .../create-file-name-input.stories.jsx | 67 - .../create-file/error-message.stories.jsx | 106 -- .../stories/modals/modal-decorators.jsx | 29 - .../unsaved-docs-locked-modal.stories.tsx | 13 - .../frontend/stories/online-users.stories.tsx | 57 - .../web/frontend/stories/outline.stories.jsx | 61 - .../stories/pdf-log-entry.stories.tsx | 93 - .../pdf-preview-error-boundary.stories.jsx | 20 - .../stories/pdf-preview-messages.stories.jsx | 27 - .../stories/pdf-preview-messages.stories.tsx | 25 - .../frontend/stories/pdf-preview.stories.jsx | 375 ---- .../frontend/stories/pdf-viewer.stories.jsx | 50 - .../project-list/add-affiliation.stories.tsx | 29 - .../project-list/color-picker.stories.tsx | 15 - ...mpile-and-download-project-pdf.stories.tsx | 65 - .../current-plan-widget.stories.tsx | 75 - .../stories/project-list/helpers/emails.ts | 119 -- .../project-list/inr-banner.stories.tsx | 10 - .../new-project-button.stories.tsx | 107 -- .../project-list/notifications.stories.tsx | 355 ---- .../notifications/confirm-email.stories.tsx | 66 - .../project-list/project-list.stories.tsx | 60 - .../project-list/project-search.stories.tsx | 27 - .../survey-widget-ds-nav.stories.tsx | 32 - .../project-list/system-messages.stories.tsx | 42 - .../stories/settings/account-info.stories.jsx | 58 - .../settings/add-email-input.stories.tsx | 30 - .../stories/settings/beta-program.stories.jsx | 27 - .../stories/settings/emails.stories.jsx | 43 - .../stories/settings/helpers/account-info.js | 23 - .../stories/settings/helpers/emails.js | 215 --- .../stories/settings/helpers/leave.js | 17 - .../stories/settings/helpers/linking.js | 78 - .../stories/settings/helpers/password.js | 35 - .../stories/settings/leave.stories.jsx | 71 - .../stories/settings/linking.stories.jsx | 96 - .../stories/settings/newsletter.stories.jsx | 10 - .../stories/settings/page.stories.jsx | 81 - .../stories/settings/password.stories.jsx | 49 - .../stories/settings/sessions.stories.jsx | 10 - .../stories/settings/sso-alert.stories.tsx | 56 - .../stories/share-project-modal.stories.jsx | 182 -- .../stories/shared/autocomplete.stories.tsx | 336 ---- .../frontend/stories/shared/badge.stories.tsx | 65 - .../shared/billing-period-toggle.stories.tsx | 84 - .../stories/shared/button.stories.tsx | 60 - .../stories/shared/buttons.stories.tsx | 68 - .../stories/shared/dropdown-menu.stories.tsx | 236 --- .../stories/shared/ds/ds-button.stories.tsx | 37 - .../shared/ds/ds-form-check.stories.tsx | 27 - .../shared/ds/ds-form-control.stories.tsx | 58 - .../shared/ds/ds-form-radio.stories.tsx | 27 - .../shared/ds/ds-notification.stories.tsx | 32 - .../shared/ds/ds-selection-group.stories.tsx | 70 - .../shared/ds/ds-six-digits-input.stories.tsx | 44 - .../stories/shared/feedback-badge.stories.tsx | 35 - .../shared/form/form-check.stories.tsx | 61 - .../shared/form/form-input.stories.tsx | 291 --- .../shared/form/form-radio.stories.tsx | 41 - .../shared/form/form-select.stories.tsx | 250 --- .../shared/form/form-textarea.stories.tsx | 257 --- .../stories/shared/icon-button.stories.tsx | 44 - .../stories/shared/input-switch.stories.tsx | 37 - .../shared/labeled-divider.stories.tsx | 18 - .../shared/loading-spinner.stories.tsx | 55 - .../stories/shared/menu-bar.stories.tsx | 35 - .../frontend/stories/shared/modal.stories.tsx | 112 -- .../stories/shared/notification.stories.tsx | 437 ----- .../stories/shared/pagination.stories.jsx | 23 - .../stories/shared/radio-chip.stories.tsx | 35 - .../frontend/stories/shared/row.stories.tsx | 29 - .../stories/shared/select.stories.tsx | 79 - .../stories/shared/split-button.stories.tsx | 63 - .../shared/split-test-badge.stories.tsx | 151 -- .../start-free-trial-button.stories.tsx | 87 - .../frontend/stories/shared/tag.stories.tsx | 84 - .../stories/shared/toggle-button.stories.tsx | 59 - .../stories/shared/tooltip.stories.tsx | 42 - .../stories/shared/upgrade-prompt.stories.tsx | 19 - .../source-editor/source-editor.stories.tsx | 553 ------ .../active-subscription.stories.tsx | 150 -- .../group-invites/group-invites.stories.tsx | 44 - .../stories/use-expand-collapse.stories.jsx | 135 -- .../web/frontend/stories/utils/arg-types.ts | 12 - .../stories/word-count-modal.stories.tsx | 78 - services/web/i18next-scanner.config.js | 50 - services/web/knip.ts | 66 - services/web/local-dev.env | 19 - services/web/local-test.env | 19 - services/web/scripts/add_feature_override.mjs | 182 -- .../scripts/add_marketing_details_to_csv.mjs | 165 -- .../add_salesforce_data_to_subscriptions.mjs | 207 --- .../scripts/add_subscription_members_csv.mjs | 202 -- .../web/scripts/add_user_count_to_csv.mjs | 96 - ...ckfill_recurly_to_subscription_mapping.mjs | 121 -- ...ackfill_stripe_to_subscription_mapping.mjs | 131 -- .../helpers/GoogleBigQueryHelper.mjs | 30 - .../sync_group_subscription_memberships.mjs | 282 --- ...g_user_personal_and_group_subscription.mjs | 80 - .../backfill_library_references_search.mjs | 113 -- .../backfill_mixpanel_user_properties.mjs | 136 -- .../web/scripts/backfill_user_properties.mjs | 61 - .../scripts/backfill_users_sso_attribute.mjs | 33 - .../web/scripts/batch_identify_to_cio.mjs | 465 ----- services/web/scripts/bench_bcrypt.mjs | 122 -- .../web/scripts/check_institution_users.mjs | 26 - .../scripts/check_overleafModuleImports.mjs | 26 - services/web/scripts/check_saml_emails.mjs | 20 - .../web/scripts/clear_feedback_collection.mjs | 41 - .../clear_institution_notifications.mjs | 50 - services/web/scripts/clear_sessions_2fa.mjs | 84 - .../scripts/count_encrypted_access_tokens.mjs | 74 - .../create_oauth_personal_access_token.mjs | 31 - .../delete_test_dupes.mjs | 91 - .../delete_old_writefull_refresh_tokens.mjs | 38 - services/web/scripts/delete_subscriptions.mjs | 47 - services/web/scripts/devcontainer_setup.mjs | 174 -- services/web/scripts/e2e_test_setup.mjs | 231 --- services/web/scripts/ensure_affiliations.mjs | 52 - services/web/scripts/esm-check-migration.mjs | 300 --- .../export_active_subscription_users_csv.mjs | 796 -------- .../web/scripts/extract_day1_churn_users.mjs | 275 --- .../extract_onboardingdatacollection_csv.mjs | 95 - ...oardingdatacollection_never_used_latex.mjs | 200 -- .../web/scripts/fetch-pyodide-packages.mjs | 136 -- .../scripts/fix_collaborator_refs_null.mjs | 175 -- services/web/scripts/fix_comment_id.mjs | 86 - .../fix_group_invite_emails_to_lowercase.mjs | 75 - .../web/scripts/fix_malformed_filetree.mjs | 306 ---- ...unconfirmed_secondaries_not_removed_v1.mjs | 261 --- ...te_recurly_customers_to_stripe.helpers.mjs | 1621 ----------------- ..._customers_to_stripe.helpers.node.test.mjs | 1148 ------------ .../insert_feature_flag_alpha_phase_0.mjs | 129 -- services/web/scripts/inst_table.mjs | 38 - services/web/scripts/investigate_esm.mjs | 147 -- services/web/scripts/ip_matcher_ranges.mjs | 80 - .../web/scripts/learn/checkSanitize/README.md | 32 - .../checkSanitize/checkSanitizeOptions.mjs | 118 -- .../web/scripts/learn/checkSanitize/index.mjs | 42 - .../scripts/learn/checkSanitize/scrape.mjs | 130 -- .../lowercase_institution_user_ids.mjs | 121 -- services/web/scripts/mark_migration.mjs | 70 - .../merge_group_subscription_members.mjs | 103 -- .../scripts/oauth/backdate_token_expiry.mjs | 124 -- .../scripts/oauth/backfill_hashed_secrets.mjs | 44 - ...l_suppress_expired_token_notifications.mjs | 69 - services/web/scripts/oauth/create_token.mjs | 97 - .../scripts/oauth/notify_expiring_tokens.mjs | 138 -- .../web/scripts/oauth/register_client.mjs | 151 -- services/web/scripts/oauth/remove_client.mjs | 122 -- .../scripts/oauth/upgrade_token_scopes.mjs | 75 - services/web/scripts/plan-prices/.gitignore | 2 - services/web/scripts/plan-prices/README.md | 17 - ...ditional-license-add-on-legacy-prices.json | 546 ------ .../web/scripts/plan-prices/package-lock.json | 65 - services/web/scripts/plan-prices/package.json | 15 - services/web/scripts/plan-prices/plans.mjs | 230 --- .../process_lapsed_reconfirmations.mjs | 9 - .../web/scripts/process_notifications.mjs | 32 - services/web/scripts/refresh_features.mjs | 195 -- .../web/scripts/refresh_institution_users.mjs | 39 - .../regenerate_duplicate_referral_ids.mjs | 109 -- .../scripts/remove_feature_from_all_users.mjs | 54 - .../web/scripts/remove_oauth_application.mjs | 34 - ...remove_unwanted_ieee_collabratec_users.mjs | 197 -- .../web/scripts/remove_user_enrollment.mjs | 93 - ...eset_rolling_build_update_notification.mjs | 40 - services/web/scripts/set_user_ai_usage.mjs | 239 --- .../web/scripts/sso_id_migration_check.mjs | 35 - .../scripts/sso_id_remove_not_migrated.mjs | 38 - services/web/scripts/stress_test.mjs | 442 ----- services/web/scripts/suspend_users.mjs | 71 - .../scripts/sync-user-entitlements/README.md | 42 - .../sync-user-entitlements.mjs | 196 -- .../web/scripts/translations/.dockerignore | 6 - services/web/scripts/translations/.gitignore | 5 - services/web/scripts/translations/Dockerfile | 7 - .../web/scripts/translations/checkCoverage.js | 41 - .../translations/checkSanitizeOptions.js | 43 - .../scripts/translations/checkVariables.js | 87 - .../translations/cleanupUnusedLocales.js | 168 -- services/web/scripts/translations/config.js | 35 - .../scripts/translations/docker-compose.yml | 12 - services/web/scripts/translations/download.js | 59 - .../translations/insertHTMLFragments.js | 63 - .../scripts/translations/package-lock.json | 468 ----- .../web/scripts/translations/package.json | 8 - .../translations/replaceLinkFragments.js | 42 - services/web/scripts/translations/sanitize.js | 46 - .../scripts/translations/transformLocales.js | 54 - .../scripts/translations/translateLocales.js | 113 -- services/web/scripts/translations/upload.js | 71 - services/web/scripts/translations/utils.js | 12 - services/web/scripts/tsconfig.json | 1 - services/web/scripts/ukamf/.gitignore | 2 - services/web/scripts/ukamf/check-certs.js | 98 - .../web/scripts/ukamf/check-idp-metadata.js | 67 - .../web/scripts/ukamf/metadata-processor.js | 72 - services/web/scripts/ukamf/package-lock.json | 40 - services/web/scripts/ukamf/package.json | 6 - services/web/scripts/ukamf/ukamf-db.js | 28 - services/web/scripts/ukamf/ukamf-entity.js | 69 - .../web/scripts/unlink_third_party_id.mjs | 88 - services/web/scripts/update_group_policy.mjs | 145 -- .../update_institution_user_saml_ids.mjs | 128 -- .../web/scripts/validate-data-of-model.mjs | 27 - services/web/scripts/verify_domains.mjs | 14 - .../scripts/verify_subscription_prices.mjs | 543 ------ .../back_fill_hiding_ai_features.mjs | 37 - .../enable_wf_autoCreatedAccount.mjs | 47 - ...e_writefull_without_autoCreatedAccount.mjs | 46 - .../writefull/migrate_to_promotion_set.mjs | 30 - .../split_writefull_disabled_from_unset.mjs | 54 - .../zotero/clear_synced_references.mjs | 101 - .../src/Scripts/FetchPyodidePackages.test.mjs | 32 - .../unit/src/Scripts/IpMatcherRange.test.mjs | 19 - .../Scripts/notify_expiring_tokens.test.mjs | 213 --- services/web/webpack.config.dev.js | 113 -- 453 files changed, 43080 deletions(-) delete mode 100644 .editorconfig delete mode 100644 develop/.gitignore delete mode 100644 develop/README.md delete mode 100755 develop/bin/build delete mode 100755 develop/bin/dev delete mode 100755 develop/bin/down delete mode 100755 develop/bin/logs delete mode 100755 develop/bin/shell delete mode 100755 develop/bin/up delete mode 100644 develop/compiles/.gitkeep delete mode 100644 develop/dev.env delete mode 100644 develop/docker-compose.dev.yml delete mode 100644 develop/docker-compose.yml delete mode 100644 develop/webpack.config.dev-env.js delete mode 100644 dockerfiles/cypress/Dockerfile delete mode 100644 libraries/access-token-encryptor/.nvmrc delete mode 100644 libraries/access-token-encryptor/Makefile delete mode 100644 libraries/access-token-encryptor/buildscript.txt delete mode 100644 libraries/fetch-utils/.nvmrc delete mode 100644 libraries/fetch-utils/Makefile delete mode 100644 libraries/fetch-utils/buildscript.txt delete mode 100644 libraries/logger/.nvmrc delete mode 100644 libraries/logger/Makefile delete mode 100644 libraries/logger/buildscript.txt delete mode 100644 libraries/metrics/.nvmrc delete mode 100644 libraries/metrics/Makefile delete mode 100644 libraries/metrics/buildscript.txt delete mode 100644 libraries/mongo-utils/.nvmrc delete mode 100644 libraries/mongo-utils/Makefile delete mode 100644 libraries/mongo-utils/buildscript.txt delete mode 100644 libraries/o-error/.nvmrc delete mode 100644 libraries/o-error/Makefile delete mode 100644 libraries/o-error/buildscript.txt delete mode 100644 libraries/object-persistor/.nvmrc delete mode 100644 libraries/object-persistor/Makefile delete mode 100644 libraries/object-persistor/buildscript.txt delete mode 100644 libraries/overleaf-editor-core/.nvmrc delete mode 100644 libraries/overleaf-editor-core/Makefile delete mode 100644 libraries/overleaf-editor-core/buildscript.txt delete mode 100644 libraries/promise-utils/.nvmrc delete mode 100644 libraries/promise-utils/Makefile delete mode 100644 libraries/promise-utils/buildscript.txt delete mode 100644 libraries/ranges-tracker/.nvmrc delete mode 100644 libraries/ranges-tracker/Makefile delete mode 100644 libraries/ranges-tracker/buildscript.txt delete mode 100644 libraries/redis-wrapper/.nvmrc delete mode 100644 libraries/redis-wrapper/Makefile delete mode 100644 libraries/redis-wrapper/buildscript.txt delete mode 100644 libraries/settings/.nvmrc delete mode 100644 libraries/settings/Makefile delete mode 100644 libraries/settings/buildscript.txt delete mode 100644 libraries/stream-utils/.nvmrc delete mode 100644 libraries/stream-utils/Makefile delete mode 100644 libraries/stream-utils/buildscript.txt delete mode 100644 libraries/validation-tools/.nvmrc delete mode 100644 libraries/validation-tools/Makefile delete mode 100644 libraries/validation-tools/buildscript.txt delete mode 100644 server-ce/eslint.config.mjs delete mode 100644 server-ce/test/.gitignore delete mode 100644 server-ce/test/Dockerfile delete mode 100644 server-ce/test/Makefile delete mode 100644 server-ce/test/accounts.spec.ts delete mode 100644 server-ce/test/admin.spec.ts delete mode 100644 server-ce/test/create-and-compile-project.spec.ts delete mode 100644 server-ce/test/customization.spec.ts delete mode 100644 server-ce/test/cypress.config.ts delete mode 100644 server-ce/test/cypress/.gitignore delete mode 100644 server-ce/test/cypress/cypress-multi-reporters.cjs delete mode 100644 server-ce/test/cypress/support/e2e.ts delete mode 100644 server-ce/test/docker-compose.native.yml delete mode 100644 server-ce/test/docker-compose.yml delete mode 100644 server-ce/test/editor.spec.ts delete mode 100644 server-ce/test/external-auth.spec.ts delete mode 100644 server-ce/test/filestore-migration.spec.ts delete mode 100644 server-ce/test/git-bridge.spec.ts delete mode 100644 server-ce/test/graceful-shutdown.spec.ts delete mode 100644 server-ce/test/helpers/beforeWithReRunOnTestRetry.ts delete mode 100644 server-ce/test/helpers/compile.ts delete mode 100644 server-ce/test/helpers/config.ts delete mode 100644 server-ce/test/helpers/email.ts delete mode 100644 server-ce/test/helpers/hostAdminClient.ts delete mode 100644 server-ce/test/helpers/login.ts delete mode 100644 server-ce/test/helpers/project.ts delete mode 100644 server-ce/test/helpers/read-file.ts delete mode 100644 server-ce/test/helpers/waitUntilScrollingFinished.ts delete mode 100644 server-ce/test/history.spec.ts delete mode 100644 server-ce/test/host-admin.js delete mode 100644 server-ce/test/learn-wiki.spec.ts delete mode 100644 server-ce/test/package.json delete mode 100644 server-ce/test/project-list.spec.ts delete mode 100644 server-ce/test/project-sharing.spec.ts delete mode 100644 server-ce/test/sandboxed-compiles.spec.ts delete mode 100644 server-ce/test/templates.spec.ts delete mode 100644 server-ce/test/tsconfig.json delete mode 100644 server-ce/test/upgrading.spec.ts delete mode 100644 services/chat/.nvmrc delete mode 100644 services/chat/Dockerfile delete mode 100644 services/chat/Makefile delete mode 100644 services/chat/buildscript.txt delete mode 100644 services/chat/docker-compose.ci.yml delete mode 100644 services/chat/docker-compose.yml delete mode 100644 services/clsi/.nvmrc delete mode 100644 services/clsi/.nycrc delete mode 100644 services/clsi/Dockerfile delete mode 100644 services/clsi/Makefile delete mode 100644 services/clsi/buildscript.txt delete mode 100644 services/clsi/docker-compose.ci.yml delete mode 100644 services/clsi/docker-compose.yml delete mode 100644 services/docstore/.nvmrc delete mode 100644 services/docstore/Dockerfile delete mode 100644 services/docstore/Makefile delete mode 100644 services/docstore/buildscript.txt delete mode 100644 services/docstore/docker-compose.ci.yml delete mode 100644 services/docstore/docker-compose.yml delete mode 100644 services/document-updater/.nvmrc delete mode 100644 services/document-updater/Dockerfile delete mode 100644 services/document-updater/Makefile delete mode 100644 services/document-updater/buildscript.txt delete mode 100644 services/document-updater/docker-compose.ci.yml delete mode 100644 services/document-updater/docker-compose.yml delete mode 100644 services/filestore/.nvmrc delete mode 100644 services/filestore/Dockerfile delete mode 100644 services/filestore/Makefile delete mode 100644 services/filestore/buildscript.txt delete mode 100644 services/filestore/docker-compose.ci.yml delete mode 100644 services/filestore/docker-compose.yml delete mode 100644 services/git-bridge/Dockerfile delete mode 100644 services/git-bridge/Makefile delete mode 100644 services/history-v1/.nvmrc delete mode 100644 services/history-v1/Dockerfile delete mode 100644 services/history-v1/Makefile delete mode 100644 services/history-v1/buildscript.txt delete mode 100644 services/history-v1/docker-compose.ci.yml delete mode 100644 services/history-v1/docker-compose.yml delete mode 100644 services/notifications/.nvmrc delete mode 100644 services/notifications/Dockerfile delete mode 100644 services/notifications/Makefile delete mode 100644 services/notifications/buildscript.txt delete mode 100644 services/notifications/docker-compose.ci.yml delete mode 100644 services/notifications/docker-compose.yml delete mode 100644 services/project-history/.nvmrc delete mode 100644 services/project-history/Dockerfile delete mode 100644 services/project-history/Makefile delete mode 100644 services/project-history/buildscript.txt delete mode 100644 services/project-history/docker-compose.ci.yml delete mode 100644 services/project-history/docker-compose.yml delete mode 100644 services/real-time/.nvmrc delete mode 100644 services/real-time/Dockerfile delete mode 100644 services/real-time/Makefile delete mode 100644 services/real-time/buildscript.txt delete mode 100644 services/real-time/docker-compose.ci.yml delete mode 100644 services/real-time/docker-compose.yml delete mode 100644 services/web/.github/prompts/await-migration.prompt.md delete mode 100644 services/web/.nvmrc delete mode 100644 services/web/.nycrc delete mode 100644 services/web/.storybook/index.html.m4 delete mode 100644 services/web/.storybook/main.ts delete mode 100644 services/web/.storybook/manager.css delete mode 100644 services/web/.storybook/manager.ts delete mode 100644 services/web/.storybook/preview.tsx delete mode 100644 services/web/.storybook/utils/figma-design-url.ts delete mode 100644 services/web/.storybook/utils/with-split-tests.tsx delete mode 100644 services/web/.stylelintrc.json delete mode 100644 services/web/.vscode/settings.json delete mode 100644 services/web/Dockerfile delete mode 100644 services/web/Dockerfile.frontend delete mode 100644 services/web/Dockerfile.frontend.ci delete mode 100644 services/web/Makefile delete mode 100644 services/web/Makefile.module delete mode 100755 services/web/bin/cdn_upload delete mode 100755 services/web/bin/cdn_upload_batch delete mode 100755 services/web/bin/check_extracted_translations delete mode 100755 services/web/bin/compress_assets delete mode 100755 services/web/bin/compress_batch_of_assets delete mode 100755 services/web/bin/copy_external_pages delete mode 100755 services/web/bin/lint_flag_res_send_usage delete mode 100755 services/web/bin/lint_locales delete mode 100755 services/web/bin/lint_pug_templates delete mode 100755 services/web/bin/push-translations-changes.sh delete mode 100755 services/web/bin/routes delete mode 100755 services/web/bin/run delete mode 100755 services/web/bin/sentry_upload delete mode 100755 services/web/bin/test_acceptance_run_dir delete mode 100755 services/web/bin/test_unit_run_dir delete mode 100644 services/web/buildscript.txt delete mode 100644 services/web/certs/DigiCertGlobalG2TLSRSASHA2562020CA1-1.crt.pem delete mode 100644 services/web/data/.gitignore delete mode 100644 services/web/data/coverage/.gitignore delete mode 100644 services/web/data/dumpFolder/.gitignore delete mode 100644 services/web/data/logs/.gitignore delete mode 100644 services/web/data/pdfs/.gitignore delete mode 100644 services/web/data/projectHistories/.gitignore delete mode 100644 services/web/data/reports/.gitignore delete mode 100644 services/web/data/uploads/.gitignore delete mode 100644 services/web/data/zippedProjects/.gitignore delete mode 100644 services/web/docker-compose.ci.yml delete mode 100644 services/web/docker-compose.common.env delete mode 100644 services/web/docker-compose.cypress.yml delete mode 100644 services/web/docker-compose.yml delete mode 100644 services/web/eslint.config.mjs delete mode 100644 services/web/frontend/stories/chat.stories.jsx delete mode 100644 services/web/frontend/stories/clone-project-modal.stories.jsx delete mode 100644 services/web/frontend/stories/contact-us-modal.stories.tsx delete mode 100644 services/web/frontend/stories/decorators/ide-redesign.tsx delete mode 100644 services/web/frontend/stories/decorators/scope.tsx delete mode 100644 services/web/frontend/stories/deprecated-browser.stories.tsx delete mode 100644 services/web/frontend/stories/docs/cloud-builds.mdx delete mode 100644 services/web/frontend/stories/docs/feature-flags.mdx delete mode 100644 services/web/frontend/stories/docs/format-currency.stories.tsx delete mode 100644 services/web/frontend/stories/docs/foundations.mdx delete mode 100644 services/web/frontend/stories/editor-switch.stories.jsx delete mode 100644 services/web/frontend/stories/editor/pdf-compile-button.stories.tsx delete mode 100644 services/web/frontend/stories/editor/synctex-toasts.stories.tsx delete mode 100644 services/web/frontend/stories/file-tree.stories.jsx delete mode 100644 services/web/frontend/stories/file-view/file-view.stories.jsx delete mode 100644 services/web/frontend/stories/fixtures/chat-messages.js delete mode 100644 services/web/frontend/stories/fixtures/compile.js delete mode 100644 services/web/frontend/stories/fixtures/contacts.js delete mode 100644 services/web/frontend/stories/fixtures/document.ts delete mode 100644 services/web/frontend/stories/fixtures/file-tree-base.js delete mode 100644 services/web/frontend/stories/fixtures/file-tree-limit.js delete mode 100644 services/web/frontend/stories/fixtures/project.ts delete mode 100644 services/web/frontend/stories/fixtures/storybook-example.pdf delete mode 100644 services/web/frontend/stories/history/document-diff-viewer.stories.tsx delete mode 100644 services/web/frontend/stories/history/history-version.stories.tsx delete mode 100644 services/web/frontend/stories/history/label-version.stories.tsx delete mode 100644 services/web/frontend/stories/history/toggle-switch.stories.tsx delete mode 100644 services/web/frontend/stories/hooks/use-fetch-mock.tsx delete mode 100644 services/web/frontend/stories/hooks/use-meta.tsx delete mode 100644 services/web/frontend/stories/hooks/use-scope.tsx delete mode 100644 services/web/frontend/stories/hotkeys-modal.stories.tsx delete mode 100644 services/web/frontend/stories/loading/loading.stories.tsx delete mode 100644 services/web/frontend/stories/modals/create-file/create-file-modal-decorator.jsx delete mode 100644 services/web/frontend/stories/modals/create-file/create-file-modal-footer.stories.jsx delete mode 100644 services/web/frontend/stories/modals/create-file/create-file-modal.stories.jsx delete mode 100644 services/web/frontend/stories/modals/create-file/create-file-name-input.stories.jsx delete mode 100644 services/web/frontend/stories/modals/create-file/error-message.stories.jsx delete mode 100644 services/web/frontend/stories/modals/modal-decorators.jsx delete mode 100644 services/web/frontend/stories/modals/unsaved-docs-locked-modal.stories.tsx delete mode 100644 services/web/frontend/stories/online-users.stories.tsx delete mode 100644 services/web/frontend/stories/outline.stories.jsx delete mode 100644 services/web/frontend/stories/pdf-log-entry.stories.tsx delete mode 100644 services/web/frontend/stories/pdf-preview-error-boundary.stories.jsx delete mode 100644 services/web/frontend/stories/pdf-preview-messages.stories.jsx delete mode 100644 services/web/frontend/stories/pdf-preview-messages.stories.tsx delete mode 100644 services/web/frontend/stories/pdf-preview.stories.jsx delete mode 100644 services/web/frontend/stories/pdf-viewer.stories.jsx delete mode 100644 services/web/frontend/stories/project-list/add-affiliation.stories.tsx delete mode 100644 services/web/frontend/stories/project-list/color-picker.stories.tsx delete mode 100644 services/web/frontend/stories/project-list/compile-and-download-project-pdf.stories.tsx delete mode 100644 services/web/frontend/stories/project-list/current-plan-widget.stories.tsx delete mode 100644 services/web/frontend/stories/project-list/helpers/emails.ts delete mode 100644 services/web/frontend/stories/project-list/inr-banner.stories.tsx delete mode 100644 services/web/frontend/stories/project-list/new-project-button.stories.tsx delete mode 100644 services/web/frontend/stories/project-list/notifications.stories.tsx delete mode 100644 services/web/frontend/stories/project-list/notifications/confirm-email.stories.tsx delete mode 100644 services/web/frontend/stories/project-list/project-list.stories.tsx delete mode 100644 services/web/frontend/stories/project-list/project-search.stories.tsx delete mode 100644 services/web/frontend/stories/project-list/survey-widget-ds-nav.stories.tsx delete mode 100644 services/web/frontend/stories/project-list/system-messages.stories.tsx delete mode 100644 services/web/frontend/stories/settings/account-info.stories.jsx delete mode 100644 services/web/frontend/stories/settings/add-email-input.stories.tsx delete mode 100644 services/web/frontend/stories/settings/beta-program.stories.jsx delete mode 100644 services/web/frontend/stories/settings/emails.stories.jsx delete mode 100644 services/web/frontend/stories/settings/helpers/account-info.js delete mode 100644 services/web/frontend/stories/settings/helpers/emails.js delete mode 100644 services/web/frontend/stories/settings/helpers/leave.js delete mode 100644 services/web/frontend/stories/settings/helpers/linking.js delete mode 100644 services/web/frontend/stories/settings/helpers/password.js delete mode 100644 services/web/frontend/stories/settings/leave.stories.jsx delete mode 100644 services/web/frontend/stories/settings/linking.stories.jsx delete mode 100644 services/web/frontend/stories/settings/newsletter.stories.jsx delete mode 100644 services/web/frontend/stories/settings/page.stories.jsx delete mode 100644 services/web/frontend/stories/settings/password.stories.jsx delete mode 100644 services/web/frontend/stories/settings/sessions.stories.jsx delete mode 100644 services/web/frontend/stories/settings/sso-alert.stories.tsx delete mode 100644 services/web/frontend/stories/share-project-modal.stories.jsx delete mode 100644 services/web/frontend/stories/shared/autocomplete.stories.tsx delete mode 100644 services/web/frontend/stories/shared/badge.stories.tsx delete mode 100644 services/web/frontend/stories/shared/billing-period-toggle.stories.tsx delete mode 100644 services/web/frontend/stories/shared/button.stories.tsx delete mode 100644 services/web/frontend/stories/shared/buttons.stories.tsx delete mode 100644 services/web/frontend/stories/shared/dropdown-menu.stories.tsx delete mode 100644 services/web/frontend/stories/shared/ds/ds-button.stories.tsx delete mode 100644 services/web/frontend/stories/shared/ds/ds-form-check.stories.tsx delete mode 100644 services/web/frontend/stories/shared/ds/ds-form-control.stories.tsx delete mode 100644 services/web/frontend/stories/shared/ds/ds-form-radio.stories.tsx delete mode 100644 services/web/frontend/stories/shared/ds/ds-notification.stories.tsx delete mode 100644 services/web/frontend/stories/shared/ds/ds-selection-group.stories.tsx delete mode 100644 services/web/frontend/stories/shared/ds/ds-six-digits-input.stories.tsx delete mode 100644 services/web/frontend/stories/shared/feedback-badge.stories.tsx delete mode 100644 services/web/frontend/stories/shared/form/form-check.stories.tsx delete mode 100644 services/web/frontend/stories/shared/form/form-input.stories.tsx delete mode 100644 services/web/frontend/stories/shared/form/form-radio.stories.tsx delete mode 100644 services/web/frontend/stories/shared/form/form-select.stories.tsx delete mode 100644 services/web/frontend/stories/shared/form/form-textarea.stories.tsx delete mode 100644 services/web/frontend/stories/shared/icon-button.stories.tsx delete mode 100644 services/web/frontend/stories/shared/input-switch.stories.tsx delete mode 100644 services/web/frontend/stories/shared/labeled-divider.stories.tsx delete mode 100644 services/web/frontend/stories/shared/loading-spinner.stories.tsx delete mode 100644 services/web/frontend/stories/shared/menu-bar.stories.tsx delete mode 100644 services/web/frontend/stories/shared/modal.stories.tsx delete mode 100644 services/web/frontend/stories/shared/notification.stories.tsx delete mode 100644 services/web/frontend/stories/shared/pagination.stories.jsx delete mode 100644 services/web/frontend/stories/shared/radio-chip.stories.tsx delete mode 100644 services/web/frontend/stories/shared/row.stories.tsx delete mode 100644 services/web/frontend/stories/shared/select.stories.tsx delete mode 100644 services/web/frontend/stories/shared/split-button.stories.tsx delete mode 100644 services/web/frontend/stories/shared/split-test-badge.stories.tsx delete mode 100644 services/web/frontend/stories/shared/start-free-trial-button.stories.tsx delete mode 100644 services/web/frontend/stories/shared/tag.stories.tsx delete mode 100644 services/web/frontend/stories/shared/toggle-button.stories.tsx delete mode 100644 services/web/frontend/stories/shared/tooltip.stories.tsx delete mode 100644 services/web/frontend/stories/shared/upgrade-prompt.stories.tsx delete mode 100644 services/web/frontend/stories/source-editor/source-editor.stories.tsx delete mode 100644 services/web/frontend/stories/subscription/active-subscription.stories.tsx delete mode 100644 services/web/frontend/stories/subscription/group-invites/group-invites.stories.tsx delete mode 100644 services/web/frontend/stories/use-expand-collapse.stories.jsx delete mode 100644 services/web/frontend/stories/utils/arg-types.ts delete mode 100644 services/web/frontend/stories/word-count-modal.stories.tsx delete mode 100644 services/web/i18next-scanner.config.js delete mode 100644 services/web/knip.ts delete mode 100644 services/web/local-dev.env delete mode 100644 services/web/local-test.env delete mode 100644 services/web/scripts/add_feature_override.mjs delete mode 100644 services/web/scripts/add_marketing_details_to_csv.mjs delete mode 100755 services/web/scripts/add_salesforce_data_to_subscriptions.mjs delete mode 100644 services/web/scripts/add_subscription_members_csv.mjs delete mode 100644 services/web/scripts/add_user_count_to_csv.mjs delete mode 100644 services/web/scripts/analytics/backfill_recurly_to_subscription_mapping.mjs delete mode 100644 services/web/scripts/analytics/backfill_stripe_to_subscription_mapping.mjs delete mode 100644 services/web/scripts/analytics/helpers/GoogleBigQueryHelper.mjs delete mode 100644 services/web/scripts/analytics/sync_group_subscription_memberships.mjs delete mode 100644 services/web/scripts/back_fill_warning_user_personal_and_group_subscription.mjs delete mode 100644 services/web/scripts/backfill_library_references_search.mjs delete mode 100644 services/web/scripts/backfill_mixpanel_user_properties.mjs delete mode 100644 services/web/scripts/backfill_user_properties.mjs delete mode 100644 services/web/scripts/backfill_users_sso_attribute.mjs delete mode 100644 services/web/scripts/batch_identify_to_cio.mjs delete mode 100644 services/web/scripts/bench_bcrypt.mjs delete mode 100644 services/web/scripts/check_institution_users.mjs delete mode 100644 services/web/scripts/check_overleafModuleImports.mjs delete mode 100644 services/web/scripts/check_saml_emails.mjs delete mode 100644 services/web/scripts/clear_feedback_collection.mjs delete mode 100644 services/web/scripts/clear_institution_notifications.mjs delete mode 100644 services/web/scripts/clear_sessions_2fa.mjs delete mode 100644 services/web/scripts/count_encrypted_access_tokens.mjs delete mode 100644 services/web/scripts/create_oauth_personal_access_token.mjs delete mode 100644 services/web/scripts/delete-duplicate-splittest-versions/delete_test_dupes.mjs delete mode 100644 services/web/scripts/delete_old_writefull_refresh_tokens.mjs delete mode 100644 services/web/scripts/delete_subscriptions.mjs delete mode 100644 services/web/scripts/devcontainer_setup.mjs delete mode 100644 services/web/scripts/e2e_test_setup.mjs delete mode 100644 services/web/scripts/ensure_affiliations.mjs delete mode 100644 services/web/scripts/esm-check-migration.mjs delete mode 100644 services/web/scripts/export_active_subscription_users_csv.mjs delete mode 100644 services/web/scripts/extract_day1_churn_users.mjs delete mode 100644 services/web/scripts/extract_onboardingdatacollection_csv.mjs delete mode 100644 services/web/scripts/extract_onboardingdatacollection_never_used_latex.mjs delete mode 100644 services/web/scripts/fetch-pyodide-packages.mjs delete mode 100644 services/web/scripts/fix_collaborator_refs_null.mjs delete mode 100644 services/web/scripts/fix_comment_id.mjs delete mode 100644 services/web/scripts/fix_group_invite_emails_to_lowercase.mjs delete mode 100644 services/web/scripts/fix_malformed_filetree.mjs delete mode 100644 services/web/scripts/fix_unconfirmed_secondaries_not_removed_v1.mjs delete mode 100644 services/web/scripts/helpers/migrate_recurly_customers_to_stripe.helpers.mjs delete mode 100644 services/web/scripts/helpers/migrate_recurly_customers_to_stripe.helpers.node.test.mjs delete mode 100644 services/web/scripts/insert_feature_flag_alpha_phase_0.mjs delete mode 100644 services/web/scripts/inst_table.mjs delete mode 100644 services/web/scripts/investigate_esm.mjs delete mode 100644 services/web/scripts/ip_matcher_ranges.mjs delete mode 100644 services/web/scripts/learn/checkSanitize/README.md delete mode 100644 services/web/scripts/learn/checkSanitize/checkSanitizeOptions.mjs delete mode 100644 services/web/scripts/learn/checkSanitize/index.mjs delete mode 100644 services/web/scripts/learn/checkSanitize/scrape.mjs delete mode 100644 services/web/scripts/lowercase_institution_user_ids.mjs delete mode 100644 services/web/scripts/mark_migration.mjs delete mode 100644 services/web/scripts/merge_group_subscription_members.mjs delete mode 100644 services/web/scripts/oauth/backdate_token_expiry.mjs delete mode 100644 services/web/scripts/oauth/backfill_hashed_secrets.mjs delete mode 100644 services/web/scripts/oauth/backfill_suppress_expired_token_notifications.mjs delete mode 100644 services/web/scripts/oauth/create_token.mjs delete mode 100644 services/web/scripts/oauth/notify_expiring_tokens.mjs delete mode 100644 services/web/scripts/oauth/register_client.mjs delete mode 100644 services/web/scripts/oauth/remove_client.mjs delete mode 100644 services/web/scripts/oauth/upgrade_token_scopes.mjs delete mode 100644 services/web/scripts/plan-prices/.gitignore delete mode 100644 services/web/scripts/plan-prices/README.md delete mode 100644 services/web/scripts/plan-prices/additional-license-add-on-legacy-prices.json delete mode 100644 services/web/scripts/plan-prices/package-lock.json delete mode 100644 services/web/scripts/plan-prices/package.json delete mode 100644 services/web/scripts/plan-prices/plans.mjs delete mode 100644 services/web/scripts/process_lapsed_reconfirmations.mjs delete mode 100644 services/web/scripts/process_notifications.mjs delete mode 100644 services/web/scripts/refresh_features.mjs delete mode 100644 services/web/scripts/refresh_institution_users.mjs delete mode 100644 services/web/scripts/regenerate_duplicate_referral_ids.mjs delete mode 100644 services/web/scripts/remove_feature_from_all_users.mjs delete mode 100644 services/web/scripts/remove_oauth_application.mjs delete mode 100644 services/web/scripts/remove_unwanted_ieee_collabratec_users.mjs delete mode 100644 services/web/scripts/remove_user_enrollment.mjs delete mode 100644 services/web/scripts/reset_rolling_build_update_notification.mjs delete mode 100644 services/web/scripts/set_user_ai_usage.mjs delete mode 100644 services/web/scripts/sso_id_migration_check.mjs delete mode 100644 services/web/scripts/sso_id_remove_not_migrated.mjs delete mode 100644 services/web/scripts/stress_test.mjs delete mode 100644 services/web/scripts/suspend_users.mjs delete mode 100644 services/web/scripts/sync-user-entitlements/README.md delete mode 100644 services/web/scripts/sync-user-entitlements/sync-user-entitlements.mjs delete mode 100644 services/web/scripts/translations/.dockerignore delete mode 100644 services/web/scripts/translations/.gitignore delete mode 100644 services/web/scripts/translations/Dockerfile delete mode 100644 services/web/scripts/translations/checkCoverage.js delete mode 100644 services/web/scripts/translations/checkSanitizeOptions.js delete mode 100644 services/web/scripts/translations/checkVariables.js delete mode 100644 services/web/scripts/translations/cleanupUnusedLocales.js delete mode 100644 services/web/scripts/translations/config.js delete mode 100644 services/web/scripts/translations/docker-compose.yml delete mode 100644 services/web/scripts/translations/download.js delete mode 100644 services/web/scripts/translations/insertHTMLFragments.js delete mode 100644 services/web/scripts/translations/package-lock.json delete mode 100644 services/web/scripts/translations/package.json delete mode 100644 services/web/scripts/translations/replaceLinkFragments.js delete mode 100644 services/web/scripts/translations/sanitize.js delete mode 100644 services/web/scripts/translations/transformLocales.js delete mode 100644 services/web/scripts/translations/translateLocales.js delete mode 100644 services/web/scripts/translations/upload.js delete mode 100644 services/web/scripts/translations/utils.js delete mode 100644 services/web/scripts/tsconfig.json delete mode 100644 services/web/scripts/ukamf/.gitignore delete mode 100644 services/web/scripts/ukamf/check-certs.js delete mode 100644 services/web/scripts/ukamf/check-idp-metadata.js delete mode 100644 services/web/scripts/ukamf/metadata-processor.js delete mode 100644 services/web/scripts/ukamf/package-lock.json delete mode 100644 services/web/scripts/ukamf/package.json delete mode 100644 services/web/scripts/ukamf/ukamf-db.js delete mode 100644 services/web/scripts/ukamf/ukamf-entity.js delete mode 100644 services/web/scripts/unlink_third_party_id.mjs delete mode 100644 services/web/scripts/update_group_policy.mjs delete mode 100644 services/web/scripts/update_institution_user_saml_ids.mjs delete mode 100644 services/web/scripts/validate-data-of-model.mjs delete mode 100644 services/web/scripts/verify_domains.mjs delete mode 100644 services/web/scripts/verify_subscription_prices.mjs delete mode 100644 services/web/scripts/writefull/back_fill_hiding_ai_features.mjs delete mode 100644 services/web/scripts/writefull/enable_wf_autoCreatedAccount.mjs delete mode 100644 services/web/scripts/writefull/enable_writefull_without_autoCreatedAccount.mjs delete mode 100644 services/web/scripts/writefull/migrate_to_promotion_set.mjs delete mode 100644 services/web/scripts/writefull/split_writefull_disabled_from_unset.mjs delete mode 100644 services/web/scripts/zotero/clear_synced_references.mjs delete mode 100644 services/web/test/unit/src/Scripts/FetchPyodidePackages.test.mjs delete mode 100644 services/web/test/unit/src/Scripts/IpMatcherRange.test.mjs delete mode 100644 services/web/test/unit/src/Scripts/notify_expiring_tokens.test.mjs delete mode 100644 services/web/webpack.config.dev.js diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index eaf034abc8..0000000000 --- a/.editorconfig +++ /dev/null @@ -1,25 +0,0 @@ -root = true - -[*] -charset = utf-8 -indent_style = space -indent_size = 2 -end_of_line = lf -insert_final_newline = true -trim_trailing_whitespace = true - -[Makefile] -indent_style = tab - -[*.go] -indent_style = tab - -[*.{pug,coffee}] -indent_style = tab - -[*.{pug,patch}] -trim_trailing_whitespace = false - -[Jenkinsfile] -insert_final_newline = false -max_line_length = off diff --git a/develop/.gitignore b/develop/.gitignore deleted file mode 100644 index bb5888b864..0000000000 --- a/develop/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -/compiles/* -!.gitkeep -.env \ No newline at end of file diff --git a/develop/README.md b/develop/README.md deleted file mode 100644 index 2b494cc70b..0000000000 --- a/develop/README.md +++ /dev/null @@ -1,76 +0,0 @@ -# Overleaf Community Edition, development environment - -## Building and running - -In this `develop` directory, build the services: - -```shell -bin/build -``` - -> [!NOTE] -> If Docker is running out of RAM while building the services in parallel, create a `.env` file in this directory containing `COMPOSE_PARALLEL_LIMIT=1`. - -Then start the services: - -```shell -bin/up -``` - -Once the services are running, open to create the first admin account. - -## Development - -To avoid running `bin/build && bin/up` after every code change, you can run Overleaf -Community Edition in _development mode_, where services will automatically update on code changes. - -To do this, use the included `bin/dev` script: - -```shell -bin/dev -``` - -This will start all services using `node --watch`, which will automatically monitor the code and restart the services as necessary. - -To improve performance, you can start only a subset of the services in development mode by providing a space-separated list to the `bin/dev` script: - -```shell -bin/dev [service1] [service2] ... [serviceN] -``` - -> [!NOTE] -> Starting the `web` service in _development mode_ will only update the `web` -> service when backend code changes. In order to automatically update frontend -> code as well, make sure to start the `webpack` service in _development mode_ -> as well. - -If no services are named, all services will start in development mode. - -## Debugging - -When run in _development mode_ most services expose a debugging port to which -you can attach a debugger such as -[the inspector in Chrome's Dev Tools](chrome://inspect/) or one integrated into -an IDE. The following table shows the port exposed on the **host machine** for -each service: - -| Service | Port | -| ------------------ | ---- | -| `web` | 9229 | -| `clsi` | 9230 | -| `chat` | 9231 | -| `docstore` | 9233 | -| `document-updater` | 9234 | -| `filestore` | 9235 | -| `notifications` | 9236 | -| `real-time` | 9237 | -| `history-v1` | 9239 | -| `project-history` | 9240 | - -To attach to a service using Chrome's _remote debugging_, go to - and make sure _Discover network targets_ is checked. Next -click _Configure..._ and add an entry `localhost:[service port]` for each of the -services you want to attach a debugger to. - -After adding an entry, the service will show up as a _Remote Target_ that you -can inspect and debug. diff --git a/develop/bin/build b/develop/bin/build deleted file mode 100755 index bd30abc13f..0000000000 --- a/develop/bin/build +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env bash - -docker compose build --pull "$@" diff --git a/develop/bin/dev b/develop/bin/dev deleted file mode 100755 index 1dca01c92b..0000000000 --- a/develop/bin/dev +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env bash - -docker-compose -f docker-compose.yml -f docker-compose.dev.yml up --no-deps --detach "$@" diff --git a/develop/bin/down b/develop/bin/down deleted file mode 100755 index 3eaad36736..0000000000 --- a/develop/bin/down +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env bash - -docker compose down "$@" diff --git a/develop/bin/logs b/develop/bin/logs deleted file mode 100755 index 3288ec05da..0000000000 --- a/develop/bin/logs +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env bash - -docker compose logs --follow --tail 10 --no-color "$@" \ - | ggrep --line-buffered --invert-match "global.gc" \ - | ggrep --line-buffered --invert-match "health.check" \ - | ggrep --line-buffered --invert-match "slow event loop" \ - | ggrep --line-buffered --invert-match "process.memoryUsage" \ - | ggrep --line-buffered --only-matching "[{].*" \ - | bunyan --output short diff --git a/develop/bin/shell b/develop/bin/shell deleted file mode 100755 index 0929c51d12..0000000000 --- a/develop/bin/shell +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env bash - -docker compose exec -it "$@" /bin/bash diff --git a/develop/bin/up b/develop/bin/up deleted file mode 100755 index b39c4e3710..0000000000 --- a/develop/bin/up +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env bash - -docker compose up --detach "$@" diff --git a/develop/compiles/.gitkeep b/develop/compiles/.gitkeep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/develop/dev.env b/develop/dev.env deleted file mode 100644 index 35da8a8c86..0000000000 --- a/develop/dev.env +++ /dev/null @@ -1,24 +0,0 @@ -CHAT_HOST=chat -CLSI_HOST=clsi -DOWNLOAD_HOST=clsi-nginx -DOCSTORE_HOST=docstore -DOCUMENT_UPDATER_HOST=document-updater -FILESTORE_HOST=filestore -GRACEFUL_SHUTDOWN_DELAY_SECONDS=0 -HISTORY_V1_HOST=history-v1 -HISTORY_REDIS_HOST=redis -LISTEN_ADDRESS=0.0.0.0 -MONGO_HOST=mongo -MONGO_URL=mongodb://mongo/sharelatex?directConnection=true -NOTIFICATIONS_HOST=notifications -PROJECT_HISTORY_HOST=project-history -QUEUES_REDIS_HOST=redis -DSMP_REDIS_HOST=redis -REALTIME_HOST=real-time -REDIS_HOST=redis -SESSION_SECRET=foo -V1_HISTORY_HOST=history-v1 -WEBPACK_HOST=webpack -WEB_API_PASSWORD=overleaf -WEB_API_USER=overleaf -WEB_HOST=web diff --git a/develop/docker-compose.dev.yml b/develop/docker-compose.dev.yml deleted file mode 100644 index 69b31f803d..0000000000 --- a/develop/docker-compose.dev.yml +++ /dev/null @@ -1,128 +0,0 @@ -services: - clsi: - command: ["node", "--watch", "app.js"] - environment: - - NODE_OPTIONS=--inspect=0.0.0.0:9229 - ports: - - "127.0.0.1:9230:9229" - volumes: - - ../services/clsi/app:/overleaf/services/clsi/app - - ../services/clsi/app.js:/overleaf/services/clsi/app.js - - ../services/clsi/config:/overleaf/services/clsi/config - - chat: - command: ["node", "--watch", "app.js"] - environment: - - NODE_OPTIONS=--inspect=0.0.0.0:9229 - ports: - - "127.0.0.1:9231:9229" - volumes: - - ../services/chat/app:/overleaf/services/chat/app - - ../services/chat/app.js:/overleaf/services/chat/app.js - - ../services/chat/config:/overleaf/services/chat/config - - docstore: - command: ["node", "--watch", "app.js"] - environment: - - NODE_OPTIONS=--inspect=0.0.0.0:9229 - ports: - - "127.0.0.1:9233:9229" - volumes: - - ../services/docstore/app:/overleaf/services/docstore/app - - ../services/docstore/app.js:/overleaf/services/docstore/app.js - - ../services/docstore/config:/overleaf/services/docstore/config - - document-updater: - command: ["node", "--watch", "app.js"] - environment: - - NODE_OPTIONS=--inspect=0.0.0.0:9229 - ports: - - "127.0.0.1:9234:9229" - volumes: - - ../services/document-updater/app:/overleaf/services/document-updater/app - - ../services/document-updater/app.js:/overleaf/services/document-updater/app.js - - ../services/document-updater/config:/overleaf/services/document-updater/config - - filestore: - command: ["node", "--watch", "app.js"] - environment: - - NODE_OPTIONS=--inspect=0.0.0.0:9229 - ports: - - "127.0.0.1:9235:9229" - volumes: - - ../services/filestore/app:/overleaf/services/filestore/app - - ../services/filestore/app.js:/overleaf/services/filestore/app.js - - ../services/filestore/config:/overleaf/services/filestore/config - - history-v1: - command: ["node", "--watch", "app.js"] - environment: - - NODE_OPTIONS=--inspect=0.0.0.0:9229 - ports: - - "127.0.0.1:9239:9229" - volumes: - - ../services/history-v1/api:/overleaf/services/history-v1/api - - ../services/history-v1/app.js:/overleaf/services/history-v1/app.js - - ../services/history-v1/config:/overleaf/services/history-v1/config - - ../services/history-v1/storage:/overleaf/services/history-v1/storage - - ../services/history-v1/knexfile.js:/overleaf/services/history-v1/knexfile.js - - ../services/history-v1/migrations:/overleaf/services/history-v1/migrations - - notifications: - command: ["node", "--watch", "app.ts"] - environment: - - NODE_OPTIONS=--inspect=0.0.0.0:9229 - ports: - - "127.0.0.1:9236:9229" - volumes: - - ../services/notifications/app:/overleaf/services/notifications/app - - ../services/notifications/app.ts:/overleaf/services/notifications/app.ts - - ../services/notifications/config:/overleaf/services/notifications/config - - project-history: - command: ["node", "--watch", "app.js"] - environment: - - NODE_OPTIONS=--inspect=0.0.0.0:9229 - ports: - - "127.0.0.1:9240:9229" - volumes: - - ../services/project-history/app:/overleaf/services/project-history/app - - ../services/project-history/app.js:/overleaf/services/project-history/app.js - - ../services/project-history/config:/overleaf/services/project-history/config - - real-time: - command: ["node", "--watch", "app.js"] - environment: - - NODE_OPTIONS=--inspect=0.0.0.0:9229 - ports: - - "127.0.0.1:9237:9229" - volumes: - - ../services/real-time/app:/overleaf/services/real-time/app - - ../services/real-time/app.js:/overleaf/services/real-time/app.js - - ../services/real-time/config:/overleaf/services/real-time/config - - web: - command: ["node", "--watch", "app.mjs", "--watch-locales"] - environment: - - NODE_OPTIONS=--inspect=0.0.0.0:9229 - ports: - - "127.0.0.1:9229:9229" - volumes: - - ../services/web/app:/overleaf/services/web/app - - ../services/web/app.mjs:/overleaf/services/web/app.mjs - - ../services/web/config:/overleaf/services/web/config - - ../services/web/locales:/overleaf/services/web/locales - - ../services/web/modules:/overleaf/services/web/modules - - ../services/web/public:/overleaf/services/web/public - - webpack: - volumes: - - ../services/web/app:/overleaf/services/web/app - - ../services/web/config:/overleaf/services/web/config - - ../services/web/frontend:/overleaf/services/web/frontend - - ../services/web/locales:/overleaf/services/web/locales - - ../services/web/modules:/overleaf/services/web/modules - - ../services/web/public:/overleaf/services/web/public - - ../services/web/transform:/overleaf/services/web/transform - - ../services/web/types:/overleaf/services/web/types - - ../services/web/webpack-plugins:/overleaf/services/web/webpack-plugins diff --git a/develop/docker-compose.yml b/develop/docker-compose.yml deleted file mode 100644 index bf65609f53..0000000000 --- a/develop/docker-compose.yml +++ /dev/null @@ -1,175 +0,0 @@ -volumes: - clsi-cache: - filestore-public-files: - filestore-template-files: - filestore-uploads: - filestore-user-files: - mongo-data: - redis-data: - sharelatex-data: - web-data: - history-v1-buckets: - -services: - chat: - build: - context: .. - dockerfile: services/chat/Dockerfile - env_file: - - dev.env - - clsi: - build: - context: .. - dockerfile: services/clsi/Dockerfile - target: with-texlive - env_file: - - dev.env - environment: - - SANDBOXED_COMPILES=false - user: root - volumes: - - ${PWD}/compiles:/overleaf/services/clsi/compiles - - ${PWD}/output:/overleaf/services/clsi/output - - ${DOCKER_SOCKET_PATH:-/var/run/docker.sock}:/var/run/docker.sock - - clsi-cache:/overleaf/services/clsi/cache - - clsi-nginx: - image: nginx:1.28 - read_only: true - tmpfs: - - /tmp - - /var/cache/nginx - - /run - volumes: - - ${PWD}/output:/output:ro - - ../services/clsi/nginx.conf:/etc/nginx/conf.d/nginx.conf:ro - - docstore: - build: - context: .. - dockerfile: services/docstore/Dockerfile - env_file: - - dev.env - - document-updater: - build: - context: .. - dockerfile: services/document-updater/Dockerfile - env_file: - - dev.env - - filestore: - build: - context: .. - dockerfile: services/filestore/Dockerfile - env_file: - - dev.env - # environment: - # - ENABLE_CONVERSIONS=true - volumes: - - filestore-public-files:/overleaf/services/filestore/public_files - - filestore-template-files:/overleaf/services/filestore/template_files - - filestore-uploads:/overleaf/services/filestore/uploads - - history-v1: - build: - context: .. - dockerfile: services/history-v1/Dockerfile - env_file: - - dev.env - environment: - OVERLEAF_EDITOR_ANALYTICS_BUCKET: "/buckets/analytics" - OVERLEAF_EDITOR_BLOBS_BUCKET: "/buckets/blobs" - OVERLEAF_EDITOR_CHUNKS_BUCKET: "/buckets/chunks" - OVERLEAF_EDITOR_PROJECT_BLOBS_BUCKET: "/buckets/project_blobs" - OVERLEAF_EDITOR_ZIPS_BUCKET: "/buckets/zips" - PERSISTOR_BACKEND: fs - volumes: - - history-v1-buckets:/buckets - - mongo: - image: mongo:8 - command: --replSet overleaf - ports: - - "127.0.0.1:27017:27017" # for debugging - volumes: - - mongo-data:/data/db - - ../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js - environment: - MONGO_INITDB_DATABASE: sharelatex - extra_hosts: - # Required when using the automatic database setup for initializing the - # replica set. This override is not needed when running the setup after - # starting up mongo. - - mongo:127.0.0.1 - - notifications: - build: - context: .. - dockerfile: services/notifications/Dockerfile - env_file: - - dev.env - - project-history: - build: - context: .. - dockerfile: services/project-history/Dockerfile - env_file: - - dev.env - - real-time: - build: - context: .. - dockerfile: services/real-time/Dockerfile - env_file: - - dev.env - - redis: - image: redis:7 - ports: - - "127.0.0.1:6379:6379" # for debugging - volumes: - - redis-data:/data - - web: - build: - context: .. - dockerfile: services/web/Dockerfile - target: dev - env_file: - - dev.env - environment: - - APP_NAME=Overleaf Community Edition - - ENABLED_LINKED_FILE_TYPES=project_file,project_output_file - - EMAIL_CONFIRMATION_DISABLED=true - - NODE_ENV=development - - OVERLEAF_ALLOW_PUBLIC_ACCESS=true - command: ["node", "app.mjs"] - volumes: - - sharelatex-data:/var/lib/overleaf - - web-data:/overleaf/services/web/data - depends_on: - - mongo - - redis - - chat - - clsi - - docstore - - document-updater - - filestore - - history-v1 - - notifications - - project-history - - real-time - - webpack: - build: - context: .. - dockerfile: services/web/Dockerfile - target: webpack - command: - ["npx", "webpack", "serve", "--config", "webpack.config.dev-env.js"] - ports: - - "127.0.0.1:80:3808" - volumes: - - ./webpack.config.dev-env.js:/overleaf/services/web/webpack.config.dev-env.js diff --git a/develop/webpack.config.dev-env.js b/develop/webpack.config.dev-env.js deleted file mode 100644 index 49f85408a8..0000000000 --- a/develop/webpack.config.dev-env.js +++ /dev/null @@ -1,23 +0,0 @@ -const { merge } = require('webpack-merge') - -const base = require('./webpack.config.dev') - -module.exports = merge(base, { - devServer: { - allowedHosts: 'auto', - devMiddleware: { - index: false, - }, - proxy: [ - { - context: '/socket.io/**', - target: 'http://real-time:3026', - ws: true, - }, - { - context: ['!**/*.js', '!**/*.css', '!**/*.json'], - target: 'http://web:3000', - }, - ], - }, -}) diff --git a/dockerfiles/cypress/Dockerfile b/dockerfiles/cypress/Dockerfile deleted file mode 100644 index b6042a0ea3..0000000000 --- a/dockerfiles/cypress/Dockerfile +++ /dev/null @@ -1,18 +0,0 @@ -FROM cypress/included:15.12.0 -ARG USER_UID=1000 -ARG USER_GID=1000 - -# Corepack setup, shared between all the images. -ENV PATH="/overleaf/node_modules/.bin:$PATH" -ENV COREPACK_HOME=/opt/corepack -RUN corepack enable && corepack install -g yarn@4.14.1 -ENV COREPACK_ENABLE_NETWORK=0 - -WORKDIR /overleaf - -RUN sed -i s/node:x:1000:/node:x:${USER_GID}:/ /etc/group \ -&& sed -i s_node:x:1000:1000::/home/node:/bin/bash_node:x:${USER_UID}:${USER_GID}::/home/node:/bin/bash_ /etc/passwd \ -&& chown -R node:node /home/node \ -&& chown node:node /overleaf - -USER node diff --git a/libraries/access-token-encryptor/.nvmrc b/libraries/access-token-encryptor/.nvmrc deleted file mode 100644 index 8e35034890..0000000000 --- a/libraries/access-token-encryptor/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -24.14.1 diff --git a/libraries/access-token-encryptor/Makefile b/libraries/access-token-encryptor/Makefile deleted file mode 100644 index 1ea1f5ce7f..0000000000 --- a/libraries/access-token-encryptor/Makefile +++ /dev/null @@ -1,59 +0,0 @@ -# Run the test commands in the scope of the monorepo. -# Eslint and prettier (plus some configs) are on the root. -RUN_LINTING = ../../bin/run -w /overleaf/libraries/access-token-encryptor monorepo yarn run --silent -RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent - -WITH_CACHE_FOLDER = \ - lint \ - lint_ci \ - lint_fix \ - format \ - format_ci \ - format_fix \ - typecheck \ - typecheck_ci \ - -$(WITH_CACHE_FOLDER): ../../node_modules/.cache/prettier/ -../../node_modules/.cache/prettier/: - mkdir -p $@ - -$(WITH_CACHE_FOLDER): ../../node_modules/.cache/eslint/ -../../node_modules/.cache/eslint/: - mkdir -p $@ - -format: - $(RUN_LINTING_MONOREPO) format libraries/access-token-encryptor - -format_ci: - $(RUN_LINTING_MONOREPO) format libraries/access-token-encryptor - -format_fix: - $(RUN_LINTING_MONOREPO) format:fix libraries/access-token-encryptor - -lint: - $(RUN_LINTING) lint - -lint_ci: - -$(RUN_LINTING) lint --format json --output-file reports/eslint.json - sed -i 's_"filePath":"/overleaf_"filePath":"$(MONOREPO)_g' reports/eslint.json - -lint_fix: - $(RUN_LINTING) lint:fix - -typecheck: - $(RUN_LINTING) types:check - -typecheck_ci: - $(RUN_LINTING) types:check - -test: test_unit -test_unit: -ifneq (,$(wildcard test/unit)) - $(RUN_LINTING) test:unit -endif - -test: test_acceptance -test_acceptance: -ifneq (,$(wildcard test/acceptance)) - $(RUN_LINTING) test:acceptance -endif diff --git a/libraries/access-token-encryptor/buildscript.txt b/libraries/access-token-encryptor/buildscript.txt deleted file mode 100644 index 81715c79c8..0000000000 --- a/libraries/access-token-encryptor/buildscript.txt +++ /dev/null @@ -1,9 +0,0 @@ -access-token-encryptor ---dependencies=None ---env-add= ---env-pass-through= ---esmock-loader=False ---is-library=True ---node-version=24.14.1 ---pipeline-owner=32 ---public-repo=False diff --git a/libraries/fetch-utils/.nvmrc b/libraries/fetch-utils/.nvmrc deleted file mode 100644 index 8e35034890..0000000000 --- a/libraries/fetch-utils/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -24.14.1 diff --git a/libraries/fetch-utils/Makefile b/libraries/fetch-utils/Makefile deleted file mode 100644 index 0b6e32ed77..0000000000 --- a/libraries/fetch-utils/Makefile +++ /dev/null @@ -1,59 +0,0 @@ -# Run the test commands in the scope of the monorepo. -# Eslint and prettier (plus some configs) are on the root. -RUN_LINTING = ../../bin/run -w /overleaf/libraries/fetch-utils monorepo yarn run --silent -RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent - -WITH_CACHE_FOLDER = \ - lint \ - lint_ci \ - lint_fix \ - format \ - format_ci \ - format_fix \ - typecheck \ - typecheck_ci \ - -$(WITH_CACHE_FOLDER): ../../node_modules/.cache/prettier/ -../../node_modules/.cache/prettier/: - mkdir -p $@ - -$(WITH_CACHE_FOLDER): ../../node_modules/.cache/eslint/ -../../node_modules/.cache/eslint/: - mkdir -p $@ - -format: - $(RUN_LINTING_MONOREPO) format libraries/fetch-utils - -format_ci: - $(RUN_LINTING_MONOREPO) format libraries/fetch-utils - -format_fix: - $(RUN_LINTING_MONOREPO) format:fix libraries/fetch-utils - -lint: - $(RUN_LINTING) lint - -lint_ci: - -$(RUN_LINTING) lint --format json --output-file reports/eslint.json - sed -i 's_"filePath":"/overleaf_"filePath":"$(MONOREPO)_g' reports/eslint.json - -lint_fix: - $(RUN_LINTING) lint:fix - -typecheck: - $(RUN_LINTING) types:check - -typecheck_ci: - $(RUN_LINTING) types:check - -test: test_unit -test_unit: -ifneq (,$(wildcard test/unit)) - $(RUN_LINTING) test:unit -endif - -test: test_acceptance -test_acceptance: -ifneq (,$(wildcard test/acceptance)) - $(RUN_LINTING) test:acceptance -endif diff --git a/libraries/fetch-utils/buildscript.txt b/libraries/fetch-utils/buildscript.txt deleted file mode 100644 index 75739abe50..0000000000 --- a/libraries/fetch-utils/buildscript.txt +++ /dev/null @@ -1,9 +0,0 @@ -fetch-utils ---dependencies=None ---env-add= ---env-pass-through= ---esmock-loader=False ---is-library=True ---node-version=24.14.1 ---pipeline-owner=32 ---public-repo=False diff --git a/libraries/logger/.nvmrc b/libraries/logger/.nvmrc deleted file mode 100644 index 8e35034890..0000000000 --- a/libraries/logger/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -24.14.1 diff --git a/libraries/logger/Makefile b/libraries/logger/Makefile deleted file mode 100644 index ffcd2f5aee..0000000000 --- a/libraries/logger/Makefile +++ /dev/null @@ -1,59 +0,0 @@ -# Run the test commands in the scope of the monorepo. -# Eslint and prettier (plus some configs) are on the root. -RUN_LINTING = ../../bin/run -w /overleaf/libraries/logger monorepo yarn run --silent -RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent - -WITH_CACHE_FOLDER = \ - lint \ - lint_ci \ - lint_fix \ - format \ - format_ci \ - format_fix \ - typecheck \ - typecheck_ci \ - -$(WITH_CACHE_FOLDER): ../../node_modules/.cache/prettier/ -../../node_modules/.cache/prettier/: - mkdir -p $@ - -$(WITH_CACHE_FOLDER): ../../node_modules/.cache/eslint/ -../../node_modules/.cache/eslint/: - mkdir -p $@ - -format: - $(RUN_LINTING_MONOREPO) format libraries/logger - -format_ci: - $(RUN_LINTING_MONOREPO) format libraries/logger - -format_fix: - $(RUN_LINTING_MONOREPO) format:fix libraries/logger - -lint: - $(RUN_LINTING) lint - -lint_ci: - -$(RUN_LINTING) lint --format json --output-file reports/eslint.json - sed -i 's_"filePath":"/overleaf_"filePath":"$(MONOREPO)_g' reports/eslint.json - -lint_fix: - $(RUN_LINTING) lint:fix - -typecheck: - $(RUN_LINTING) types:check - -typecheck_ci: - $(RUN_LINTING) types:check - -test: test_unit -test_unit: -ifneq (,$(wildcard test/unit)) - $(RUN_LINTING) test:unit -endif - -test: test_acceptance -test_acceptance: -ifneq (,$(wildcard test/acceptance)) - $(RUN_LINTING) test:acceptance -endif diff --git a/libraries/logger/buildscript.txt b/libraries/logger/buildscript.txt deleted file mode 100644 index c7dac83e16..0000000000 --- a/libraries/logger/buildscript.txt +++ /dev/null @@ -1,9 +0,0 @@ -logger ---dependencies=None ---env-add= ---env-pass-through= ---esmock-loader=False ---is-library=True ---node-version=24.14.1 ---pipeline-owner=32 ---public-repo=False diff --git a/libraries/metrics/.nvmrc b/libraries/metrics/.nvmrc deleted file mode 100644 index 8e35034890..0000000000 --- a/libraries/metrics/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -24.14.1 diff --git a/libraries/metrics/Makefile b/libraries/metrics/Makefile deleted file mode 100644 index c215d44659..0000000000 --- a/libraries/metrics/Makefile +++ /dev/null @@ -1,59 +0,0 @@ -# Run the test commands in the scope of the monorepo. -# Eslint and prettier (plus some configs) are on the root. -RUN_LINTING = ../../bin/run -w /overleaf/libraries/metrics monorepo yarn run --silent -RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent - -WITH_CACHE_FOLDER = \ - lint \ - lint_ci \ - lint_fix \ - format \ - format_ci \ - format_fix \ - typecheck \ - typecheck_ci \ - -$(WITH_CACHE_FOLDER): ../../node_modules/.cache/prettier/ -../../node_modules/.cache/prettier/: - mkdir -p $@ - -$(WITH_CACHE_FOLDER): ../../node_modules/.cache/eslint/ -../../node_modules/.cache/eslint/: - mkdir -p $@ - -format: - $(RUN_LINTING_MONOREPO) format libraries/metrics - -format_ci: - $(RUN_LINTING_MONOREPO) format libraries/metrics - -format_fix: - $(RUN_LINTING_MONOREPO) format:fix libraries/metrics - -lint: - $(RUN_LINTING) lint - -lint_ci: - -$(RUN_LINTING) lint --format json --output-file reports/eslint.json - sed -i 's_"filePath":"/overleaf_"filePath":"$(MONOREPO)_g' reports/eslint.json - -lint_fix: - $(RUN_LINTING) lint:fix - -typecheck: - $(RUN_LINTING) types:check - -typecheck_ci: - $(RUN_LINTING) types:check - -test: test_unit -test_unit: -ifneq (,$(wildcard test/unit)) - $(RUN_LINTING) test:unit -endif - -test: test_acceptance -test_acceptance: -ifneq (,$(wildcard test/acceptance)) - $(RUN_LINTING) test:acceptance -endif diff --git a/libraries/metrics/buildscript.txt b/libraries/metrics/buildscript.txt deleted file mode 100644 index 1fdfe2d004..0000000000 --- a/libraries/metrics/buildscript.txt +++ /dev/null @@ -1,9 +0,0 @@ -metrics ---dependencies=None ---env-add= ---env-pass-through= ---esmock-loader=False ---is-library=True ---node-version=24.14.1 ---pipeline-owner=32 ---public-repo=False diff --git a/libraries/mongo-utils/.nvmrc b/libraries/mongo-utils/.nvmrc deleted file mode 100644 index 8e35034890..0000000000 --- a/libraries/mongo-utils/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -24.14.1 diff --git a/libraries/mongo-utils/Makefile b/libraries/mongo-utils/Makefile deleted file mode 100644 index 8005de3691..0000000000 --- a/libraries/mongo-utils/Makefile +++ /dev/null @@ -1,59 +0,0 @@ -# Run the test commands in the scope of the monorepo. -# Eslint and prettier (plus some configs) are on the root. -RUN_LINTING = ../../bin/run -w /overleaf/libraries/mongo-utils monorepo yarn run --silent -RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent - -WITH_CACHE_FOLDER = \ - lint \ - lint_ci \ - lint_fix \ - format \ - format_ci \ - format_fix \ - typecheck \ - typecheck_ci \ - -$(WITH_CACHE_FOLDER): ../../node_modules/.cache/prettier/ -../../node_modules/.cache/prettier/: - mkdir -p $@ - -$(WITH_CACHE_FOLDER): ../../node_modules/.cache/eslint/ -../../node_modules/.cache/eslint/: - mkdir -p $@ - -format: - $(RUN_LINTING_MONOREPO) format libraries/mongo-utils - -format_ci: - $(RUN_LINTING_MONOREPO) format libraries/mongo-utils - -format_fix: - $(RUN_LINTING_MONOREPO) format:fix libraries/mongo-utils - -lint: - $(RUN_LINTING) lint - -lint_ci: - -$(RUN_LINTING) lint --format json --output-file reports/eslint.json - sed -i 's_"filePath":"/overleaf_"filePath":"$(MONOREPO)_g' reports/eslint.json - -lint_fix: - $(RUN_LINTING) lint:fix - -typecheck: - $(RUN_LINTING) types:check - -typecheck_ci: - $(RUN_LINTING) types:check - -test: test_unit -test_unit: -ifneq (,$(wildcard test/unit)) - $(RUN_LINTING) test:unit -endif - -test: test_acceptance -test_acceptance: -ifneq (,$(wildcard test/acceptance)) - $(RUN_LINTING) test:acceptance -endif diff --git a/libraries/mongo-utils/buildscript.txt b/libraries/mongo-utils/buildscript.txt deleted file mode 100644 index a677218ba0..0000000000 --- a/libraries/mongo-utils/buildscript.txt +++ /dev/null @@ -1,10 +0,0 @@ -mongo-utils ---dependencies=None ---env-add= ---env-pass-through= ---esmock-loader=False ---is-library=True ---node-version=24.14.1 ---pipeline-owner=32 ---public-repo=False ---tsconfig-no-implicit-any=True diff --git a/libraries/o-error/.nvmrc b/libraries/o-error/.nvmrc deleted file mode 100644 index 8e35034890..0000000000 --- a/libraries/o-error/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -24.14.1 diff --git a/libraries/o-error/Makefile b/libraries/o-error/Makefile deleted file mode 100644 index c8cce47422..0000000000 --- a/libraries/o-error/Makefile +++ /dev/null @@ -1,59 +0,0 @@ -# Run the test commands in the scope of the monorepo. -# Eslint and prettier (plus some configs) are on the root. -RUN_LINTING = ../../bin/run -w /overleaf/libraries/o-error monorepo yarn run --silent -RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent - -WITH_CACHE_FOLDER = \ - lint \ - lint_ci \ - lint_fix \ - format \ - format_ci \ - format_fix \ - typecheck \ - typecheck_ci \ - -$(WITH_CACHE_FOLDER): ../../node_modules/.cache/prettier/ -../../node_modules/.cache/prettier/: - mkdir -p $@ - -$(WITH_CACHE_FOLDER): ../../node_modules/.cache/eslint/ -../../node_modules/.cache/eslint/: - mkdir -p $@ - -format: - $(RUN_LINTING_MONOREPO) format libraries/o-error - -format_ci: - $(RUN_LINTING_MONOREPO) format libraries/o-error - -format_fix: - $(RUN_LINTING_MONOREPO) format:fix libraries/o-error - -lint: - $(RUN_LINTING) lint - -lint_ci: - -$(RUN_LINTING) lint --format json --output-file reports/eslint.json - sed -i 's_"filePath":"/overleaf_"filePath":"$(MONOREPO)_g' reports/eslint.json - -lint_fix: - $(RUN_LINTING) lint:fix - -typecheck: - $(RUN_LINTING) types:check - -typecheck_ci: - $(RUN_LINTING) types:check - -test: test_unit -test_unit: -ifneq (,$(wildcard test/unit)) - $(RUN_LINTING) test:unit -endif - -test: test_acceptance -test_acceptance: -ifneq (,$(wildcard test/acceptance)) - $(RUN_LINTING) test:acceptance -endif diff --git a/libraries/o-error/buildscript.txt b/libraries/o-error/buildscript.txt deleted file mode 100644 index 97f08794b6..0000000000 --- a/libraries/o-error/buildscript.txt +++ /dev/null @@ -1,9 +0,0 @@ -o-error ---dependencies=None ---env-add= ---env-pass-through= ---esmock-loader=False ---is-library=True ---node-version=24.14.1 ---pipeline-owner=32 ---public-repo=False diff --git a/libraries/object-persistor/.nvmrc b/libraries/object-persistor/.nvmrc deleted file mode 100644 index 8e35034890..0000000000 --- a/libraries/object-persistor/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -24.14.1 diff --git a/libraries/object-persistor/Makefile b/libraries/object-persistor/Makefile deleted file mode 100644 index 32123b3633..0000000000 --- a/libraries/object-persistor/Makefile +++ /dev/null @@ -1,59 +0,0 @@ -# Run the test commands in the scope of the monorepo. -# Eslint and prettier (plus some configs) are on the root. -RUN_LINTING = ../../bin/run -w /overleaf/libraries/object-persistor monorepo yarn run --silent -RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent - -WITH_CACHE_FOLDER = \ - lint \ - lint_ci \ - lint_fix \ - format \ - format_ci \ - format_fix \ - typecheck \ - typecheck_ci \ - -$(WITH_CACHE_FOLDER): ../../node_modules/.cache/prettier/ -../../node_modules/.cache/prettier/: - mkdir -p $@ - -$(WITH_CACHE_FOLDER): ../../node_modules/.cache/eslint/ -../../node_modules/.cache/eslint/: - mkdir -p $@ - -format: - $(RUN_LINTING_MONOREPO) format libraries/object-persistor - -format_ci: - $(RUN_LINTING_MONOREPO) format libraries/object-persistor - -format_fix: - $(RUN_LINTING_MONOREPO) format:fix libraries/object-persistor - -lint: - $(RUN_LINTING) lint - -lint_ci: - -$(RUN_LINTING) lint --format json --output-file reports/eslint.json - sed -i 's_"filePath":"/overleaf_"filePath":"$(MONOREPO)_g' reports/eslint.json - -lint_fix: - $(RUN_LINTING) lint:fix - -typecheck: - $(RUN_LINTING) types:check - -typecheck_ci: - $(RUN_LINTING) types:check - -test: test_unit -test_unit: -ifneq (,$(wildcard test/unit)) - $(RUN_LINTING) test:unit -endif - -test: test_acceptance -test_acceptance: -ifneq (,$(wildcard test/acceptance)) - $(RUN_LINTING) test:acceptance -endif diff --git a/libraries/object-persistor/buildscript.txt b/libraries/object-persistor/buildscript.txt deleted file mode 100644 index aebfb17c56..0000000000 --- a/libraries/object-persistor/buildscript.txt +++ /dev/null @@ -1,10 +0,0 @@ -object-persistor ---dependencies=None ---env-add= ---env-pass-through= ---esmock-loader=False ---is-library=True ---node-version=24.14.1 ---pipeline-owner=32 ---public-repo=False ---tsconfig-no-implicit-any=True diff --git a/libraries/overleaf-editor-core/.nvmrc b/libraries/overleaf-editor-core/.nvmrc deleted file mode 100644 index 8e35034890..0000000000 --- a/libraries/overleaf-editor-core/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -24.14.1 diff --git a/libraries/overleaf-editor-core/Makefile b/libraries/overleaf-editor-core/Makefile deleted file mode 100644 index e3290027c7..0000000000 --- a/libraries/overleaf-editor-core/Makefile +++ /dev/null @@ -1,59 +0,0 @@ -# Run the test commands in the scope of the monorepo. -# Eslint and prettier (plus some configs) are on the root. -RUN_LINTING = ../../bin/run -w /overleaf/libraries/overleaf-editor-core monorepo yarn run --silent -RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent - -WITH_CACHE_FOLDER = \ - lint \ - lint_ci \ - lint_fix \ - format \ - format_ci \ - format_fix \ - typecheck \ - typecheck_ci \ - -$(WITH_CACHE_FOLDER): ../../node_modules/.cache/prettier/ -../../node_modules/.cache/prettier/: - mkdir -p $@ - -$(WITH_CACHE_FOLDER): ../../node_modules/.cache/eslint/ -../../node_modules/.cache/eslint/: - mkdir -p $@ - -format: - $(RUN_LINTING_MONOREPO) format libraries/overleaf-editor-core - -format_ci: - $(RUN_LINTING_MONOREPO) format libraries/overleaf-editor-core - -format_fix: - $(RUN_LINTING_MONOREPO) format:fix libraries/overleaf-editor-core - -lint: - $(RUN_LINTING) lint - -lint_ci: - -$(RUN_LINTING) lint --format json --output-file reports/eslint.json - sed -i 's_"filePath":"/overleaf_"filePath":"$(MONOREPO)_g' reports/eslint.json - -lint_fix: - $(RUN_LINTING) lint:fix - -typecheck: - $(RUN_LINTING) types:check - -typecheck_ci: - $(RUN_LINTING) types:check - -test: test_unit -test_unit: -ifneq (,$(wildcard test/unit)) - $(RUN_LINTING) test:unit -endif - -test: test_acceptance -test_acceptance: -ifneq (,$(wildcard test/acceptance)) - $(RUN_LINTING) test:acceptance -endif diff --git a/libraries/overleaf-editor-core/buildscript.txt b/libraries/overleaf-editor-core/buildscript.txt deleted file mode 100644 index 68a9d83f67..0000000000 --- a/libraries/overleaf-editor-core/buildscript.txt +++ /dev/null @@ -1,9 +0,0 @@ -overleaf-editor-core ---dependencies=None ---env-add= ---env-pass-through= ---esmock-loader=False ---is-library=True ---node-version=24.14.1 ---pipeline-owner=44 ---public-repo=False diff --git a/libraries/promise-utils/.nvmrc b/libraries/promise-utils/.nvmrc deleted file mode 100644 index 8e35034890..0000000000 --- a/libraries/promise-utils/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -24.14.1 diff --git a/libraries/promise-utils/Makefile b/libraries/promise-utils/Makefile deleted file mode 100644 index 75db05eb39..0000000000 --- a/libraries/promise-utils/Makefile +++ /dev/null @@ -1,59 +0,0 @@ -# Run the test commands in the scope of the monorepo. -# Eslint and prettier (plus some configs) are on the root. -RUN_LINTING = ../../bin/run -w /overleaf/libraries/promise-utils monorepo yarn run --silent -RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent - -WITH_CACHE_FOLDER = \ - lint \ - lint_ci \ - lint_fix \ - format \ - format_ci \ - format_fix \ - typecheck \ - typecheck_ci \ - -$(WITH_CACHE_FOLDER): ../../node_modules/.cache/prettier/ -../../node_modules/.cache/prettier/: - mkdir -p $@ - -$(WITH_CACHE_FOLDER): ../../node_modules/.cache/eslint/ -../../node_modules/.cache/eslint/: - mkdir -p $@ - -format: - $(RUN_LINTING_MONOREPO) format libraries/promise-utils - -format_ci: - $(RUN_LINTING_MONOREPO) format libraries/promise-utils - -format_fix: - $(RUN_LINTING_MONOREPO) format:fix libraries/promise-utils - -lint: - $(RUN_LINTING) lint - -lint_ci: - -$(RUN_LINTING) lint --format json --output-file reports/eslint.json - sed -i 's_"filePath":"/overleaf_"filePath":"$(MONOREPO)_g' reports/eslint.json - -lint_fix: - $(RUN_LINTING) lint:fix - -typecheck: - $(RUN_LINTING) types:check - -typecheck_ci: - $(RUN_LINTING) types:check - -test: test_unit -test_unit: -ifneq (,$(wildcard test/unit)) - $(RUN_LINTING) test:unit -endif - -test: test_acceptance -test_acceptance: -ifneq (,$(wildcard test/acceptance)) - $(RUN_LINTING) test:acceptance -endif diff --git a/libraries/promise-utils/buildscript.txt b/libraries/promise-utils/buildscript.txt deleted file mode 100644 index f87efde6d1..0000000000 --- a/libraries/promise-utils/buildscript.txt +++ /dev/null @@ -1,9 +0,0 @@ -promise-utils ---dependencies=None ---env-add= ---env-pass-through= ---esmock-loader=False ---is-library=True ---node-version=24.14.1 ---pipeline-owner=32 ---public-repo=False diff --git a/libraries/ranges-tracker/.nvmrc b/libraries/ranges-tracker/.nvmrc deleted file mode 100644 index 8e35034890..0000000000 --- a/libraries/ranges-tracker/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -24.14.1 diff --git a/libraries/ranges-tracker/Makefile b/libraries/ranges-tracker/Makefile deleted file mode 100644 index c043077989..0000000000 --- a/libraries/ranges-tracker/Makefile +++ /dev/null @@ -1,59 +0,0 @@ -# Run the test commands in the scope of the monorepo. -# Eslint and prettier (plus some configs) are on the root. -RUN_LINTING = ../../bin/run -w /overleaf/libraries/ranges-tracker monorepo yarn run --silent -RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent - -WITH_CACHE_FOLDER = \ - lint \ - lint_ci \ - lint_fix \ - format \ - format_ci \ - format_fix \ - typecheck \ - typecheck_ci \ - -$(WITH_CACHE_FOLDER): ../../node_modules/.cache/prettier/ -../../node_modules/.cache/prettier/: - mkdir -p $@ - -$(WITH_CACHE_FOLDER): ../../node_modules/.cache/eslint/ -../../node_modules/.cache/eslint/: - mkdir -p $@ - -format: - $(RUN_LINTING_MONOREPO) format libraries/ranges-tracker - -format_ci: - $(RUN_LINTING_MONOREPO) format libraries/ranges-tracker - -format_fix: - $(RUN_LINTING_MONOREPO) format:fix libraries/ranges-tracker - -lint: - $(RUN_LINTING) lint - -lint_ci: - -$(RUN_LINTING) lint --format json --output-file reports/eslint.json - sed -i 's_"filePath":"/overleaf_"filePath":"$(MONOREPO)_g' reports/eslint.json - -lint_fix: - $(RUN_LINTING) lint:fix - -typecheck: - $(RUN_LINTING) types:check - -typecheck_ci: - $(RUN_LINTING) types:check - -test: test_unit -test_unit: -ifneq (,$(wildcard test/unit)) - $(RUN_LINTING) test:unit -endif - -test: test_acceptance -test_acceptance: -ifneq (,$(wildcard test/acceptance)) - $(RUN_LINTING) test:acceptance -endif diff --git a/libraries/ranges-tracker/buildscript.txt b/libraries/ranges-tracker/buildscript.txt deleted file mode 100644 index 5406adf4d0..0000000000 --- a/libraries/ranges-tracker/buildscript.txt +++ /dev/null @@ -1,9 +0,0 @@ -ranges-tracker ---dependencies=None ---env-add= ---env-pass-through= ---esmock-loader=False ---is-library=True ---node-version=24.14.1 ---pipeline-owner=44 ---public-repo=False diff --git a/libraries/redis-wrapper/.nvmrc b/libraries/redis-wrapper/.nvmrc deleted file mode 100644 index 8e35034890..0000000000 --- a/libraries/redis-wrapper/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -24.14.1 diff --git a/libraries/redis-wrapper/Makefile b/libraries/redis-wrapper/Makefile deleted file mode 100644 index 3feaaaaa68..0000000000 --- a/libraries/redis-wrapper/Makefile +++ /dev/null @@ -1,59 +0,0 @@ -# Run the test commands in the scope of the monorepo. -# Eslint and prettier (plus some configs) are on the root. -RUN_LINTING = ../../bin/run -w /overleaf/libraries/redis-wrapper monorepo yarn run --silent -RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent - -WITH_CACHE_FOLDER = \ - lint \ - lint_ci \ - lint_fix \ - format \ - format_ci \ - format_fix \ - typecheck \ - typecheck_ci \ - -$(WITH_CACHE_FOLDER): ../../node_modules/.cache/prettier/ -../../node_modules/.cache/prettier/: - mkdir -p $@ - -$(WITH_CACHE_FOLDER): ../../node_modules/.cache/eslint/ -../../node_modules/.cache/eslint/: - mkdir -p $@ - -format: - $(RUN_LINTING_MONOREPO) format libraries/redis-wrapper - -format_ci: - $(RUN_LINTING_MONOREPO) format libraries/redis-wrapper - -format_fix: - $(RUN_LINTING_MONOREPO) format:fix libraries/redis-wrapper - -lint: - $(RUN_LINTING) lint - -lint_ci: - -$(RUN_LINTING) lint --format json --output-file reports/eslint.json - sed -i 's_"filePath":"/overleaf_"filePath":"$(MONOREPO)_g' reports/eslint.json - -lint_fix: - $(RUN_LINTING) lint:fix - -typecheck: - $(RUN_LINTING) types:check - -typecheck_ci: - $(RUN_LINTING) types:check - -test: test_unit -test_unit: -ifneq (,$(wildcard test/unit)) - $(RUN_LINTING) test:unit -endif - -test: test_acceptance -test_acceptance: -ifneq (,$(wildcard test/acceptance)) - $(RUN_LINTING) test:acceptance -endif diff --git a/libraries/redis-wrapper/buildscript.txt b/libraries/redis-wrapper/buildscript.txt deleted file mode 100644 index 5dbce753d6..0000000000 --- a/libraries/redis-wrapper/buildscript.txt +++ /dev/null @@ -1,9 +0,0 @@ -redis-wrapper ---dependencies=None ---env-add= ---env-pass-through= ---esmock-loader=False ---is-library=True ---node-version=24.14.1 ---pipeline-owner=32 ---public-repo=False diff --git a/libraries/settings/.nvmrc b/libraries/settings/.nvmrc deleted file mode 100644 index 8e35034890..0000000000 --- a/libraries/settings/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -24.14.1 diff --git a/libraries/settings/Makefile b/libraries/settings/Makefile deleted file mode 100644 index c695f8aed6..0000000000 --- a/libraries/settings/Makefile +++ /dev/null @@ -1,59 +0,0 @@ -# Run the test commands in the scope of the monorepo. -# Eslint and prettier (plus some configs) are on the root. -RUN_LINTING = ../../bin/run -w /overleaf/libraries/settings monorepo yarn run --silent -RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent - -WITH_CACHE_FOLDER = \ - lint \ - lint_ci \ - lint_fix \ - format \ - format_ci \ - format_fix \ - typecheck \ - typecheck_ci \ - -$(WITH_CACHE_FOLDER): ../../node_modules/.cache/prettier/ -../../node_modules/.cache/prettier/: - mkdir -p $@ - -$(WITH_CACHE_FOLDER): ../../node_modules/.cache/eslint/ -../../node_modules/.cache/eslint/: - mkdir -p $@ - -format: - $(RUN_LINTING_MONOREPO) format libraries/settings - -format_ci: - $(RUN_LINTING_MONOREPO) format libraries/settings - -format_fix: - $(RUN_LINTING_MONOREPO) format:fix libraries/settings - -lint: - $(RUN_LINTING) lint - -lint_ci: - -$(RUN_LINTING) lint --format json --output-file reports/eslint.json - sed -i 's_"filePath":"/overleaf_"filePath":"$(MONOREPO)_g' reports/eslint.json - -lint_fix: - $(RUN_LINTING) lint:fix - -typecheck: - $(RUN_LINTING) types:check - -typecheck_ci: - $(RUN_LINTING) types:check - -test: test_unit -test_unit: -ifneq (,$(wildcard test/unit)) - $(RUN_LINTING) test:unit -endif - -test: test_acceptance -test_acceptance: -ifneq (,$(wildcard test/acceptance)) - $(RUN_LINTING) test:acceptance -endif diff --git a/libraries/settings/buildscript.txt b/libraries/settings/buildscript.txt deleted file mode 100644 index 3a4a6868a0..0000000000 --- a/libraries/settings/buildscript.txt +++ /dev/null @@ -1,9 +0,0 @@ -settings ---dependencies=None ---env-add= ---env-pass-through= ---esmock-loader=False ---is-library=True ---node-version=24.14.1 ---pipeline-owner=32 ---public-repo=False diff --git a/libraries/stream-utils/.nvmrc b/libraries/stream-utils/.nvmrc deleted file mode 100644 index 8e35034890..0000000000 --- a/libraries/stream-utils/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -24.14.1 diff --git a/libraries/stream-utils/Makefile b/libraries/stream-utils/Makefile deleted file mode 100644 index 94815017e1..0000000000 --- a/libraries/stream-utils/Makefile +++ /dev/null @@ -1,59 +0,0 @@ -# Run the test commands in the scope of the monorepo. -# Eslint and prettier (plus some configs) are on the root. -RUN_LINTING = ../../bin/run -w /overleaf/libraries/stream-utils monorepo yarn run --silent -RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent - -WITH_CACHE_FOLDER = \ - lint \ - lint_ci \ - lint_fix \ - format \ - format_ci \ - format_fix \ - typecheck \ - typecheck_ci \ - -$(WITH_CACHE_FOLDER): ../../node_modules/.cache/prettier/ -../../node_modules/.cache/prettier/: - mkdir -p $@ - -$(WITH_CACHE_FOLDER): ../../node_modules/.cache/eslint/ -../../node_modules/.cache/eslint/: - mkdir -p $@ - -format: - $(RUN_LINTING_MONOREPO) format libraries/stream-utils - -format_ci: - $(RUN_LINTING_MONOREPO) format libraries/stream-utils - -format_fix: - $(RUN_LINTING_MONOREPO) format:fix libraries/stream-utils - -lint: - $(RUN_LINTING) lint - -lint_ci: - -$(RUN_LINTING) lint --format json --output-file reports/eslint.json - sed -i 's_"filePath":"/overleaf_"filePath":"$(MONOREPO)_g' reports/eslint.json - -lint_fix: - $(RUN_LINTING) lint:fix - -typecheck: - $(RUN_LINTING) types:check - -typecheck_ci: - $(RUN_LINTING) types:check - -test: test_unit -test_unit: -ifneq (,$(wildcard test/unit)) - $(RUN_LINTING) test:unit -endif - -test: test_acceptance -test_acceptance: -ifneq (,$(wildcard test/acceptance)) - $(RUN_LINTING) test:acceptance -endif diff --git a/libraries/stream-utils/buildscript.txt b/libraries/stream-utils/buildscript.txt deleted file mode 100644 index fadffdcafb..0000000000 --- a/libraries/stream-utils/buildscript.txt +++ /dev/null @@ -1,9 +0,0 @@ -stream-utils ---dependencies=None ---env-add= ---env-pass-through= ---esmock-loader=False ---is-library=True ---node-version=24.14.1 ---pipeline-owner=32 ---public-repo=False diff --git a/libraries/validation-tools/.nvmrc b/libraries/validation-tools/.nvmrc deleted file mode 100644 index 8e35034890..0000000000 --- a/libraries/validation-tools/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -24.14.1 diff --git a/libraries/validation-tools/Makefile b/libraries/validation-tools/Makefile deleted file mode 100644 index 02b2acbf7f..0000000000 --- a/libraries/validation-tools/Makefile +++ /dev/null @@ -1,59 +0,0 @@ -# Run the test commands in the scope of the monorepo. -# Eslint and prettier (plus some configs) are on the root. -RUN_LINTING = ../../bin/run -w /overleaf/libraries/validation-tools monorepo yarn run --silent -RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent - -WITH_CACHE_FOLDER = \ - lint \ - lint_ci \ - lint_fix \ - format \ - format_ci \ - format_fix \ - typecheck \ - typecheck_ci \ - -$(WITH_CACHE_FOLDER): ../../node_modules/.cache/prettier/ -../../node_modules/.cache/prettier/: - mkdir -p $@ - -$(WITH_CACHE_FOLDER): ../../node_modules/.cache/eslint/ -../../node_modules/.cache/eslint/: - mkdir -p $@ - -format: - $(RUN_LINTING_MONOREPO) format libraries/validation-tools - -format_ci: - $(RUN_LINTING_MONOREPO) format libraries/validation-tools - -format_fix: - $(RUN_LINTING_MONOREPO) format:fix libraries/validation-tools - -lint: - $(RUN_LINTING) lint - -lint_ci: - -$(RUN_LINTING) lint --format json --output-file reports/eslint.json - sed -i 's_"filePath":"/overleaf_"filePath":"$(MONOREPO)_g' reports/eslint.json - -lint_fix: - $(RUN_LINTING) lint:fix - -typecheck: - $(RUN_LINTING) types:check - -typecheck_ci: - $(RUN_LINTING) types:check - -test: test_unit -test_unit: -ifneq (,$(wildcard test/unit)) - $(RUN_LINTING) test:unit -endif - -test: test_acceptance -test_acceptance: -ifneq (,$(wildcard test/acceptance)) - $(RUN_LINTING) test:acceptance -endif diff --git a/libraries/validation-tools/buildscript.txt b/libraries/validation-tools/buildscript.txt deleted file mode 100644 index 01be6f67a8..0000000000 --- a/libraries/validation-tools/buildscript.txt +++ /dev/null @@ -1,10 +0,0 @@ -validation-tools ---dependencies=None ---env-add= ---env-pass-through= ---esmock-loader=False ---is-library=True ---node-version=24.14.1 ---public-repo=False ---test-acceptance-vitest=True ---test-unit-vitest=True diff --git a/server-ce/eslint.config.mjs b/server-ce/eslint.config.mjs deleted file mode 100644 index ae3c155726..0000000000 --- a/server-ce/eslint.config.mjs +++ /dev/null @@ -1,33 +0,0 @@ -import { defineConfig, globalIgnores } from 'eslint/config' -import cypress from 'eslint-plugin-cypress/flat' -import path from 'node:path' -import baseConfig from '../eslint.config.mjs' - -const ROOT_DIR = path.resolve(import.meta.dirname, '..') - -export default defineConfig([ - globalIgnores(['**/hotfix/', '**/develop/']), - { - basePath: ROOT_DIR, - extends: baseConfig, - languageOptions: { - ecmaVersion: 2020, - }, - }, - { - // The cypress block in baseConfig has patterns rooted at the - // monorepo root (`server-ce/test/helpers/*.ts`). When ESLint loads - // this file (server-ce/eslint.config.mjs) as the closest config -- - // which happens when running `yarn run lint` from - // /overleaf/server-ce/test/ -- patterns from baseConfig are - // resolved relative to /overleaf/server-ce/, so those cross-dir - // patterns don't match. Re-declare with paths relative to this - // config file. - files: [ - 'test/helpers/*.ts', - 'test/cypress/support/*.{js,jsx,mjs,cjs,ts,tsx}', - '**/*.spec.ts', - ], - ...cypress.configs.recommended, - }, -]) diff --git a/server-ce/test/.gitignore b/server-ce/test/.gitignore deleted file mode 100644 index 56be476514..0000000000 --- a/server-ce/test/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -data/ -docker-mailtrap/ diff --git a/server-ce/test/Dockerfile b/server-ce/test/Dockerfile deleted file mode 100644 index 5680b3e677..0000000000 --- a/server-ce/test/Dockerfile +++ /dev/null @@ -1,15 +0,0 @@ -FROM node:24.14.1 - -# Corepack setup, shared between all the images. -ENV PATH="/overleaf/node_modules/.bin:$PATH" -ENV COREPACK_HOME=/opt/corepack -RUN corepack enable && corepack install -g yarn@4.14.1 -ENV COREPACK_ENABLE_NETWORK=0 - -RUN curl -fsSL https://download.docker.com/linux/debian/gpg | apt-key add - \ -&& echo \ - "deb [arch=$(dpkg --print-architecture)] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \ - > /etc/apt/sources.list.d/docker.list \ -&& apt-get update \ -&& apt-get install -y docker-ce-cli docker-compose-plugin \ -&& rm -rf /var/lib/apt/lists/* diff --git a/server-ce/test/Makefile b/server-ce/test/Makefile deleted file mode 100644 index 367059ef04..0000000000 --- a/server-ce/test/Makefile +++ /dev/null @@ -1,126 +0,0 @@ -all: test-e2e - -# We are updating the docker compose config via the host-admin service. -# The host-admin service is running inside docker and has its own file-system layout. -# We need to have both file-system layouts agree on the path for the docker compose project. -# Notable the container labels com.docker.compose.project.working_dir and com.docker.compose.project.config_files need to match when creating containers from the docker host (how you started things) and from host-admin (how tests reconfigure the instance). -export PWD = $(shell pwd) -export MONOREPO = $(shell cd ../../ && pwd) - -export TEX_LIVE_DOCKER_IMAGE ?= us-east1-docker.pkg.dev/overleaf-ops/ol-docker/texlive-full:2023.1 -export ALL_TEX_LIVE_DOCKER_IMAGES ?= us-east1-docker.pkg.dev/overleaf-ops/ol-docker/texlive-full:2023.1,us-east1-docker.pkg.dev/overleaf-ops/ol-docker/texlive-full:2022.1 -export IMAGE_TAG_CE ?= sharelatex/sharelatex:main -export IMAGE_TAG_PRO ?= us-east1-docker.pkg.dev/overleaf-ops/ol-docker/pro:main -export IMAGE_TAG_GIT_BRIDGE ?= us-east1-docker.pkg.dev/overleaf-ops/ol-docker/git-bridge:main -export CYPRESS_SHARD ?= -export COMPOSE_PROJECT_NAME ?= test -export USER_UID=$(shell id -u) -export USER_GID=$(shell id -g) -IMAGE_TAG_PRO_BASE := $(shell echo $(IMAGE_TAG_PRO) | sed -E s/:.+//) - -print-debug-logs: - -docker compose logs --timestamps --tail=100 e2e - -docker compose logs --timestamps --tail=100 mongo - -docker compose logs --timestamps --tail=100 redis - -docker compose logs --timestamps --tail=100 mailtrap - -docker compose logs --timestamps --tail=100 saml - -docker compose logs --timestamps --tail=100 ldap - -docker compose logs --timestamps --tail=100 git-bridge - -docker compose logs --timestamps --tail=100 host-admin - -docker compose logs --timestamps sharelatex - -docker compose exec sharelatex bash -c 'tail -n100 /var/log/overleaf/*.log' - -test-e2e-native: - docker compose -f docker-compose.yml -f docker-compose.native.yml up --no-log-prefix sharelatex host-admin -d - CYPRESS_BASE_URL='http://127.0.0.1:3231' \ - CYPRESS_HOST_ADMIN_URL='http://127.0.0.1:3232' \ - CYPRESS_SAML_URL='http://127.0.0.1:3233' \ - CYPRESS_MAILTRAP_URL='http://127.0.0.1:3234' \ - yarn run cypress:open - -# For testing "native" on Linux -test-e2e-native-linux: - docker compose -f docker-compose.yml -f docker-compose.native.yml up --no-log-prefix sharelatex host-admin -d - CYPRESS_BASE_URL='http://127.0.0.1:3231' \ - CYPRESS_HOST_ADMIN_URL='http://127.0.0.1:3232' \ - CYPRESS_SAML_URL='http://127.0.0.1:3233' \ - CYPRESS_MAILTRAP_URL='http://127.0.0.1:3234' \ - docker compose -f docker-compose.yml -f docker-compose.native.yml up --no-log-prefix cypress - -test-e2e: - docker compose build host-admin e2e - docker compose up --detach --wait host-admin - docker compose up --detach --wait mongo - docker compose up --no-log-prefix --exit-code-from=e2e e2e host-admin - -test-e2e-open: - docker compose up --detach --wait host-admin - docker compose up --detach --wait mongo - docker compose up --no-log-prefix --exit-code-from=e2e-open e2e-open host-admin - -SHARD_PROJECT_NAMES = \ - test-ce-default \ - test-ce-custom-1 \ - test-pro-default-1 \ - test-pro-default-2 \ - test-pro-custom-1 \ - test-pro-custom-2 \ - test-pro-custom-3 \ - test-pro-custom-4 -CLEAN_SHARDS=$(addprefix clean/,$(SHARD_PROJECT_NAMES)) -clean: $(CLEAN_SHARDS) - -docker compose run --no-deps --rm --entrypoint rm host-admin -rf docker-compose.override.yml docker-compose.*_*.yml reports/ data/ - -docker compose down --remove-orphans --rmi local --timeout 0 --volumes - -$(CLEAN_SHARDS): clean/%: - -COMPOSE_PROJECT_NAME=$* docker compose down --remove-orphans --rmi local --timeout 0 --volumes - -prefetch: prefetch_default -prefetch_default: prefetch_default_compose -prefetch_default_compose: - docker compose pull e2e mongo redis - -prefetch_default: prefetch_default_compose_build -prefetch_default_compose_build: - docker compose build host-admin e2e - -prefetch: prefetch_custom -prefetch_custom: prefetch_custom_compose_pull -prefetch_custom_compose_pull: - docker compose pull saml ldap - -prefetch_custom: prefetch_custom_texlive_2023 -prefetch_custom_texlive_2023: - docker pull us-east1-docker.pkg.dev/overleaf-ops/ol-docker/texlive-full:2023.1 - docker tag us-east1-docker.pkg.dev/overleaf-ops/ol-docker/texlive-full:2023.1 quay.io/sharelatex/texlive-full:2023.1 - -prefetch_custom: prefetch_custom_texlive_2022 -prefetch_custom_texlive_2022: - docker pull us-east1-docker.pkg.dev/overleaf-ops/ol-docker/texlive-full:2022.1 - docker tag us-east1-docker.pkg.dev/overleaf-ops/ol-docker/texlive-full:2022.1 quay.io/sharelatex/texlive-full:2022.1 - -prefetch_custom: prefetch_old_4_2 -prefetch_old_4_2: - docker pull $(IMAGE_TAG_PRO_BASE):4.2 - -prefetch_custom: prefetch_old_5_0 -prefetch_old_5_0: - docker pull $(IMAGE_TAG_PRO_BASE):5.0 - -prefetch_custom: build_hotfix_5_5_5 -build_hotfix_5_5_5: - docker pull $(IMAGE_TAG_PRO_BASE):5.5.4 - cd ../../server-pro/hotfix/5.5.5 && docker build --build-arg BASE_IMAGE=$(IMAGE_TAG_PRO_BASE):5.5.4 -t $(IMAGE_TAG_PRO_BASE):5.5.5 . - -# Google Cloud Build runs on a very ancient Docker version that does not support the subdir flag. -# Use services -> mailtrap -> build -> context = https://github.com/dbck/docker-mailtrap.git#v1.5.0:build in docker-compose.yml eventually. -prefetch_default_compose_build: build_mailtrap -build_mailtrap: - git clone https://github.com/dbck/docker-mailtrap.git || true && cd docker-mailtrap && git checkout v1.5.0 - docker build -t mailtrap docker-mailtrap/build - -prefetch_default_compose_build: build_cypress -build_cypress: - docker compose build e2e - -.PHONY: test-e2e test-e2e-open diff --git a/server-ce/test/accounts.spec.ts b/server-ce/test/accounts.spec.ts deleted file mode 100644 index a64da80414..0000000000 --- a/server-ce/test/accounts.spec.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { createMongoUser, ensureUserExists, login } from './helpers/login' -import { isExcludedBySharding, startWith } from './helpers/config' - -describe('Accounts', function () { - if (isExcludedBySharding('CE_DEFAULT')) return - startWith({}) - ensureUserExists({ email: 'user@example.com' }) - - it('can log in and out', function () { - login('user@example.com') - cy.visit('/project') - cy.findByRole('menuitem', { name: 'Account' }).click() - cy.findByRole('menuitem', { name: 'Log Out' }).click() - cy.url().should('include', '/login') - cy.visit('/project') - cy.url().should('include', '/login') - }) - - it('should render the email on the user activate screen', function () { - const email = 'not-activated-user@example.com' - cy.then(async () => { - const { url } = await createMongoUser({ email }) - return url - }).as('url') - cy.get('@url').then(url => { - cy.visit(`${url}`) - cy.url().should('contain', '/user/activate') - cy.findByRole('heading', { name: 'Please set a password' }) - cy.findByLabelText('Email').should('be.visible') - cy.findByLabelText('Password').should('be.visible') - cy.findByRole('button', { name: 'Activate' }) - }) - }) -}) diff --git a/server-ce/test/admin.spec.ts b/server-ce/test/admin.spec.ts deleted file mode 100644 index 1c3c56a522..0000000000 --- a/server-ce/test/admin.spec.ts +++ /dev/null @@ -1,402 +0,0 @@ -import { isExcludedBySharding, startWith } from './helpers/config' -import { - activateUser, - createMongoUser, - ensureUserExists, - login, -} from './helpers/login' -import { v4 as uuid } from 'uuid' -import { createProject } from './helpers/project' -import { beforeWithReRunOnTestRetry } from './helpers/beforeWithReRunOnTestRetry' -import { openEmail } from './helpers/email' - -describe('admin panel', function () { - function registrationTests() { - it('via GUI and opening URL manually', function () { - const user = `${uuid()}@example.com` - cy.findByLabelText('Emails to register new users').type(user + '{enter}') - - cy.findByRole('cell', { name: /\/user\/activate/ }).then($td => { - const url = $td.text().trim() - activateUser(url) - }) - }) - - it('via GUI and email', function () { - const user = `${uuid()}@example.com` - cy.findByLabelText('Emails to register new users').type(user + '{enter}') - - let url: string - cy.findByRole('cell', { name: /\/user\/activate/ }).then($td => { - url = $td.text().trim() - }) - - cy.then(() => { - openEmail( - 'Activate your E2E test Account', - (frame, { url }) => { - frame.contains('a', 'Set password').then(el => { - expect(el.attr('href')!).to.equal(url) - }) - }, - { url } - ) - // Run activateUser in the main origin instead of inside openEmail. See docs on openEmail. - activateUser(url) - }) - }) - it('via script and opening URL manually', function () { - const user = `${uuid()}@example.com` - let url: string - cy.then(async () => { - ;({ url } = await createMongoUser({ email: user })) - }) - cy.then(() => { - activateUser(url) - }) - }) - it('via script and email', function () { - const user = `${uuid()}@example.com` - let url: string - cy.then(async () => { - ;({ url } = await createMongoUser({ email: user })) - }) - cy.then(() => { - openEmail( - 'Activate your E2E test Account', - (frame, { url }) => { - frame.contains('a', 'Set password').then(el => { - expect(el.attr('href')!).to.equal(url) - }) - }, - { url } - ) - // Run activateUser in the main origin instead of inside openEmail. See docs on openEmail. - activateUser(url) - }) - }) - } - - describe('in CE', function () { - if (isExcludedBySharding('CE_DEFAULT')) return - startWith({ pro: false, version: 'latest' }) - const admin = 'admin@example.com' - const user = `user+${uuid()}@example.com` - ensureUserExists({ email: admin, isAdmin: true }) - ensureUserExists({ email: user }) - - describe('create users', function () { - beforeEach(function () { - login(admin) - cy.visit('/project') - cy.findByRole('menuitem', { name: 'Admin' }).click() - cy.findByRole('menuitem', { name: 'Manage Users' }).click() - }) - registrationTests() - }) - }) - - describe('in server pro', function () { - const admin = 'admin@example.com' - const user1 = 'user@example.com' - const user2 = 'user2@example.com' - - let testProjectName = '' - let testProjectId = '' - let deletedProjectName = '' - let projectToDeleteId = '' - - const findProjectRow = (projectName: string) => { - cy.log('find project row') - return cy.findByText(projectName).parent().parent() - } - - if (isExcludedBySharding('PRO_DEFAULT_2')) return - startWith({ - pro: true, - }) - ensureUserExists({ email: admin, isAdmin: true }) - ensureUserExists({ email: user1 }) - ensureUserExists({ email: user2 }) - - beforeWithReRunOnTestRetry(() => { - testProjectName = `project-${uuid()}` - deletedProjectName = `deleted-project-${uuid()}` - login(user1) - createProject(testProjectName, { open: false }).then( - id => (testProjectId = id) - ) - createProject(deletedProjectName, { open: false }).then( - id => (projectToDeleteId = id) - ) - }) - - describe('admin menu items', function () { - beforeEach(function () { - login(admin) - cy.visit('/project') - }) - - it('displays expected admin menu items', function () { - const menuitems = ['Manage Site', 'Manage Users', 'Project URL Lookup'] - menuitems.forEach(name => { - cy.findByRole('menuitem', { name: 'Admin' }).click() - cy.findByRole('menu') - .findAllByRole('menuitem') - .should('have.length', menuitems.length) - cy.findByRole('menu').findByRole('menuitem', { name }).click() - }) - }) - }) - - describe('manage site', function () { - beforeEach(function () { - login(admin) - cy.visit('/project') - cy.findByRole('menuitem', { name: 'Admin' }).click() - cy.findByRole('menuitem', { name: 'Manage Site' }).click() - }) - - it('publish and clear admin messages', function () { - const message = 'Admin Message ' + uuid() - - cy.log('create system message') - cy.findByRole('tab', { name: 'System Messages' }).click() - cy.findByLabelText('Message').type(message) - cy.findByRole('button', { name: 'Post Message' }).click() - cy.findByText(message) - - login(user1) - cy.visit('/project') - cy.findByText(message) - - cy.log('clear system messages') - login(admin) - cy.visit('/project') - cy.findByRole('menuitem', { name: 'Admin' }).click() - cy.findByRole('menuitem', { name: 'Manage Site' }).click() - cy.findByRole('tab', { name: 'System Messages' }).click() - cy.findByRole('button', { name: 'Clear all messages' }).click() - - cy.log('verify system messages are no longer displayed') - login(user1) - cy.visit('/project') - cy.findByText(message).should('not.exist') - }) - }) - - describe('manage users', function () { - beforeEach(function () { - login(admin) - cy.visit('/project') - cy.findByRole('menuitem', { name: 'Admin' }).click() - cy.findByRole('menuitem', { name: 'Manage Users' }).click() - }) - - it('displays expected tabs', function () { - const tabs = ['Users', 'License Usage'] - cy.findAllByRole('tab').should('have.length', tabs.length) - tabs.forEach(tabName => { - cy.findByRole('tab', { name: tabName }).click() - }) - }) - - it('license usage tab', function () { - cy.findByRole('tab', { name: 'License Usage' }).click() - cy.findByText( - 'An active user is one who has opened a project in this Server Pro instance in the last 12 months.' - ) - }) - - describe('create users', function () { - beforeEach(function () { - cy.findByRole('link', { name: 'New User' }).click() - }) - registrationTests() - }) - - it('user list RegExp search', function () { - cy.findByLabelText('RegExp').click() - cy.findByPlaceholderText('Search users by email or id…').type( - 'user[0-9]{enter}' - ) - cy.findByRole('link', { name: user2 }) - cy.findByRole('link', { name: user1 }).should('not.exist') - }) - }) - - describe('user page', function () { - beforeEach(function () { - login(admin) - cy.visit('/project') - cy.findByRole('menuitem', { name: 'Admin' }).click() - cy.findByRole('menuitem', { name: 'Manage Users' }).click() - cy.findByPlaceholderText('Search users by email or id…').type( - user1 + '{enter}' - ) - cy.findByRole('link', { name: user1 }).click() - cy.url().should('match', /\/admin\/user\/[a-fA-F0-9]{24}/) - }) - - it('displays expected tabs', function () { - const tabs = [ - 'User Info', - 'Projects', - 'Deleted Projects', - 'Audit Log', - 'Sessions', - 'Personal Access Tokens', - ] - cy.findAllByRole('tab').should('have.length', tabs.length) - tabs.forEach(tabName => { - cy.findByRole('tab', { name: tabName }).click() - }) - }) - - describe('user info tab', function () { - beforeEach(function () { - cy.findByRole('tab', { name: 'User Info' }).click() - }) - - it('displays required sections', function () { - // not exhaustive list, checks the tab content is rendered - cy.findByRole('heading', { name: 'Profile' }) - cy.findByRole('heading', { name: 'Editor Settings' }) - }) - - it('should not display SaaS-only sections', function () { - cy.findByLabelText('Referred User Count').should('not.exist') - cy.findByRole('heading', { name: /Split Test Assignments/ }).should( - 'not.exist' - ) - cy.findByRole('heading', { name: 'Experimental Features' }).should( - 'not.exist' - ) - cy.findByRole('heading', { name: 'Service Integration' }).should( - 'not.exist' - ) - cy.findByRole('heading', { name: 'SSO Integrations' }).should( - 'not.exist' - ) - cy.findByRole('heading', { name: 'Security' }).should('not.exist') - }) - }) - - it('transfer project ownership', function () { - cy.log("access project admin through owners' project list") - cy.findByRole('tablist').within(() => { - cy.findByRole('tab', { name: 'Projects' }).click() - }) - cy.findByRole('link', { name: testProjectName }) - .parent() - .parent() - .within(() => { - cy.findByRole('link', { name: 'Project information' }).click() - }) - - cy.findByRole('button', { name: 'Transfer Ownership' }).click() - cy.findByRole('dialog').within(() => { - cy.findByRole('heading', { name: 'Transfer Ownership of Project' }) - cy.findByRole('button', { name: 'Find' }).should('be.disabled') - cy.findByRole('button', { name: 'Confirm' }).should('be.disabled') - cy.findByPlaceholderText('User ID or Email').type(user2) - cy.findByRole('button', { name: 'Find' }).should('not.be.disabled') - cy.findByRole('button', { name: 'Find' }).click() - cy.findByText('Transfer project to this user?') - cy.findByRole('cell', { name: 'ID' }) - cy.findByRole('cell', { name: 'Name' }) - cy.findByRole('cell', { name: 'Email' }) - cy.findByRole('cell', { name: user2 }) - cy.findByRole('button', { name: 'Confirm' }).should('not.be.disabled') - cy.findByRole('button', { name: 'Confirm' }).click() - }) - - cy.log('check the project is displayed in the new owner projects tab') - cy.findByPlaceholderText('Search users by email or id…').type( - user2 + '{enter}' - ) - cy.findByRole('link', { name: user2 }).click() - cy.findByRole('tablist').within(() => { - cy.findByRole('tab', { name: 'Projects' }).click() - }) - cy.findByRole('link', { name: testProjectName }) - .parent() - .parent() - .within(() => { - cy.findByRole('link', { name: 'Project information' }).click() - }) - }) - }) - - describe('project page', function () { - beforeEach(function () { - login(admin) - cy.visit(`/admin/project/${testProjectId}`) - }) - - it('displays expected tabs', function () { - const tabs = ['Project Info', 'Deleted Docs', 'Audit Log'] - cy.findAllByRole('tab').should('have.length', tabs.length) - tabs.forEach(tabName => { - cy.findByRole('tab', { name: tabName }).click() - }) - }) - }) - - it('restore deleted projects', function () { - login(user1) - cy.visit('/project') - - cy.log('select project to delete') - findProjectRow(deletedProjectName).within(() => - cy - .findByRole('checkbox', { name: `Select ${deletedProjectName}` }) - .first() - .check() - ) - cy.log('delete project') - findProjectRow(deletedProjectName).within(() => - cy.findByRole('button', { name: 'Trash' }).click() - ) - cy.findByRole('button', { name: 'Confirm' }).click() - cy.findByRole('link', { name: deletedProjectName }).should('not.exist') - - cy.log('navigate to thrashed projects and delete the project') - cy.findByRole('navigation', { - name: 'Project categories and tags', - }) - .findByRole('button', { name: 'Trashed projects' }) - .click() - findProjectRow(deletedProjectName).within(() => - cy.findByRole('button', { name: 'Delete' }).click() - ) - cy.findByRole('button', { name: 'Confirm' }).click() - cy.findByRole('link', { name: deletedProjectName }).should('not.exist') - - cy.log('login as an admin and navigate to the deleted project') - login(admin) - cy.visit('/admin/user') - cy.findByPlaceholderText('Search users by email or id…').type( - user1 + '{enter}' - ) - cy.findByRole('link', { name: user1 }).click() - cy.findByRole('tab', { name: 'Deleted Projects' }).click() - cy.findByRole('link', { name: deletedProjectName }).click() - - cy.log('undelete the project') - cy.findByRole('button', { name: 'Undelete' }).click() - cy.findByRole('button', { name: 'Undelete' }).should('not.exist') - cy.url().should('contain', `/admin/project/${projectToDeleteId}`) - - cy.log('login as the user and verify the project is restored') - login(user1) - cy.visit('/project') - cy.findByRole('navigation', { - name: 'Project categories and tags', - }) - .findByRole('button', { name: 'Trashed projects' }) - .click() - cy.findByRole('link', { name: `${deletedProjectName} (Restored)` }) - }) - }) -}) diff --git a/server-ce/test/create-and-compile-project.spec.ts b/server-ce/test/create-and-compile-project.spec.ts deleted file mode 100644 index ad2b1ba382..0000000000 --- a/server-ce/test/create-and-compile-project.spec.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { ensureUserExists, login } from './helpers/login' -import { - createProject, - createProjectAndOpenInNewEditor, - openProjectViaInviteNotification, -} from './helpers/project' -import { isExcludedBySharding, startWith } from './helpers/config' -import { prepareWaitForNextCompileSlot } from './helpers/compile' - -const USER = 'user@example.com' -const COLLABORATOR = 'collaborator@example.com' -describe('Project creation and compilation', function () { - if (isExcludedBySharding('CE_DEFAULT')) return - startWith({}) - ensureUserExists({ email: USER }) - ensureUserExists({ email: COLLABORATOR }) - - it('users can create project and compile it', function () { - login(USER) - const { recompile, waitForCompile, waitForCompileRateLimitCoolOff } = - prepareWaitForNextCompileSlot() - waitForCompile(() => { - createProjectAndOpenInNewEditor('test-project') - }) - cy.findByRole('textbox', { name: 'Source Editor editing' }).within(() => { - cy.findByText('\\maketitle').parent().click() - cy.findByText('\\maketitle').parent().type('\n\\section{{}Test Section}') - }) - waitForCompileRateLimitCoolOff() - recompile() - cy.findByRole('region', { name: 'PDF preview' }).within(() => { - cy.findByLabelText(/Page.*1/i).should('be.visible') - cy.findByText('Test Section').should('be.visible') - }) - }) - - it('create and edit markdown file', function () { - const fileName = `test-${Date.now()}.md` - const markdownContent = '# Markdown title' - login(USER) - createProjectAndOpenInNewEditor('test-project') - - cy.findByRole('button', { name: 'New file' }).click() - cy.findByRole('dialog').within(() => { - cy.findByLabelText('File Name').as('fileName').clear() - cy.get('@fileName').type(fileName) - cy.findByRole('button', { name: 'Create' }).click() - }) - cy.findByRole('treeitem', { name: fileName }).click() - // wait until we've switched to the newly created empty file - cy.findByRole('textbox', { name: 'Source Editor editing' }).should( - 'have.length', - 1 - ) - cy.findByRole('textbox', { name: 'Source Editor editing' }).type( - markdownContent - ) - cy.findByRole('treeitem', { name: 'main.tex' }).click() - cy.findByRole('textbox', { name: 'Source Editor editing' }).should( - 'contain.text', - '\\maketitle' - ) - cy.findByRole('treeitem', { name: fileName }).click() - cy.findByRole('textbox', { name: 'Source Editor editing' }).should( - 'contain.text', - markdownContent - ) - }) - - it('can link and display linked image from other project', function () { - const sourceProjectName = `test-project-${Date.now()}` - const targetProjectName = `${sourceProjectName}-target` - login(USER) - - createProject(sourceProjectName, { - type: 'Example project', - open: false, - newEditor: true, - }).as('sourceProjectId') - createProjectAndOpenInNewEditor(targetProjectName) - - // link the image from `projectName` into this project - cy.findByRole('button', { name: 'New file' }).click() - cy.findByRole('dialog').within(() => { - cy.findByRole('button', { name: 'From another project' }).click() - cy.findByLabelText('Select a Project').select(sourceProjectName) - cy.findByLabelText('Select a File').select('frog.jpg') - cy.findByRole('button', { name: 'Create' }).click() - }) - cy.findByRole('treeitem', { name: 'frog.jpg' }).click() - cy.findByRole('link', { name: 'Another project' }) - .should('have.attr', 'href') - .then(href => { - cy.get('@sourceProjectId').then(sourceProjectId => { - expect(href).to.equal(`/project/${sourceProjectId}`) - }) - }) - }) - - it('can refresh linked files as collaborator', function () { - const sourceProjectName = `test-project-${Date.now()}` - const targetProjectName = `${sourceProjectName}-target` - login(USER) - createProject(sourceProjectName, { - type: 'Example project', - open: false, - newEditor: true, - }).as('sourceProjectId') - createProjectAndOpenInNewEditor(targetProjectName).as('targetProjectId') - - // link the image from `projectName` into this project - cy.findByRole('button', { name: 'New file' }).click() - cy.findByRole('dialog').within(() => { - cy.findByRole('button', { name: 'From another project' }).click() - cy.findByLabelText('Select a Project').select(sourceProjectName) - cy.findByLabelText('Select a File').select('frog.jpg') - cy.findByRole('button', { name: 'Create' }).click() - }) - - cy.findByRole('navigation', { name: 'Project actions' }).within(() => { - cy.findByRole('button', { name: 'Share' }).click() - }) - cy.findByRole('dialog').within(() => { - cy.findByTestId('collaborator-email-input').type(COLLABORATOR + ',') - cy.findByRole('button', { name: 'Invite' }).click() - cy.findByText('Invite not yet accepted.') - }) - - login(COLLABORATOR) - openProjectViaInviteNotification(targetProjectName, true) - cy.get('@targetProjectId').then(targetProjectId => { - cy.url().should('include', targetProjectId) - }) - - cy.findByRole('treeitem', { name: 'frog.jpg' }).click() - cy.findByRole('link', { name: 'Another project' }) - .should('have.attr', 'href') - .then(href => { - cy.get('@sourceProjectId').then(sourceProjectId => { - expect(href).to.equal(`/project/${sourceProjectId}`) - }) - }) - }) -}) diff --git a/server-ce/test/customization.spec.ts b/server-ce/test/customization.spec.ts deleted file mode 100644 index af8503c680..0000000000 --- a/server-ce/test/customization.spec.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { isExcludedBySharding, startWith } from './helpers/config' - -describe('Customization', function () { - if (isExcludedBySharding('CE_CUSTOM_1')) return - - describe('default settings', function () { - startWith({}) - - it('should display the default right footer', function () { - cy.visit('/') - cy.findByRole('contentinfo').findByRole('link', { - name: 'Fork on GitHub!', - }) - }) - }) - - describe('custom settings', function () { - startWith({ - vars: { - OVERLEAF_APP_NAME: 'CUSTOM APP NAME', - OVERLEAF_LEFT_FOOTER: JSON.stringify([{ text: 'CUSTOM LEFT FOOTER' }]), - OVERLEAF_RIGHT_FOOTER: JSON.stringify([ - { text: 'CUSTOM RIGHT FOOTER' }, - ]), - }, - }) - - it('should display custom name', function () { - cy.visit('/') - cy.findByRole('navigation', { name: 'Primary' }).findByText( - 'CUSTOM APP NAME' - ) - }) - - it('should display custom left footer', function () { - cy.visit('/') - cy.findByRole('contentinfo').findByText('CUSTOM LEFT FOOTER') - }) - it('should display custom right footer', function () { - cy.visit('/') - cy.findByRole('contentinfo').findByText('CUSTOM RIGHT FOOTER') - }) - }) -}) diff --git a/server-ce/test/cypress.config.ts b/server-ce/test/cypress.config.ts deleted file mode 100644 index e04e8a5384..0000000000 --- a/server-ce/test/cypress.config.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { defineConfig } from 'cypress' -import { readFileInZip, readPdf } from './helpers/read-file' -import fs from 'node:fs' - -if (process.env.CYPRESS_SHARD && !process.env.SPEC_PATTERN) { - // Running Cypress on all the specs is wasteful (~1min) when only few of them - // will have relevant tasks to run for a given shard. Filter the spec files - // based on the existence of the shard identifier in the spec source. - const files = [] - for (const name of fs.readdirSync('.')) { - if (!name.endsWith('.spec.ts')) continue - const src = fs.readFileSync(name, 'utf-8') - if (!src.includes('isExcludedBySharding(')) { - throw new Error( - `Spec ${name} is not using sharding. Add an appropriate "'if (isExcludedBySharding('...')) return" call.` - ) - } - if (!src.includes(process.env.CYPRESS_SHARD)) continue - files.push(name) - } - if (files.length === 0) { - throw new Error( - `Bad process.env.CYPRESS_SHARD=${process.env.CYPRESS_SHARD}; no spec files matched!` - ) - } - if (files.length === 1) { - // Cypress does not like `{single-file}`. Make this a special case. - process.env.SPEC_PATTERN = `./${files[0]}` - } else { - process.env.SPEC_PATTERN = `./{${files.join(',')}}` - } -} - -const specPattern = process.env.SPEC_PATTERN || './**/*.spec.ts' - -let reporterOptions = {} -if (process.env.CI) { - reporterOptions = { - reporter: `${process.env.MONOREPO}/node_modules/cypress-multi-reporters`, - reporterOptions: { - configFile: 'cypress/cypress-multi-reporters.cjs', - }, - } -} - -export default defineConfig({ - defaultCommandTimeout: 10_000, - fixturesFolder: 'cypress/fixtures', - video: process.env.CYPRESS_VIDEO === 'true', - screenshotsFolder: 'cypress/results', - videosFolder: 'cypress/results', - viewportHeight: 768, - viewportWidth: 1024, - e2e: { - setupNodeEvents(on) { - on('task', { - readPdf, - readFileInZip, - }) - }, - specPattern, - }, - retries: { - runMode: 3, - }, - ...reporterOptions, -}) diff --git a/server-ce/test/cypress/.gitignore b/server-ce/test/cypress/.gitignore deleted file mode 100644 index 5b36de24c2..0000000000 --- a/server-ce/test/cypress/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -downloads/ -results/ -data/ diff --git a/server-ce/test/cypress/cypress-multi-reporters.cjs b/server-ce/test/cypress/cypress-multi-reporters.cjs deleted file mode 100644 index 869ddd1547..0000000000 --- a/server-ce/test/cypress/cypress-multi-reporters.cjs +++ /dev/null @@ -1,10 +0,0 @@ -module.exports = { - reporterEnabled: 'spec, mocha-junit-reporter', - mochaJunitReporterReporterOptions: { - mochaFile: `reports/junit-${process.env.CYPRESS_SHARD}-[suiteFilename].xml`, - includePending: true, - jenkinsMode: true, - jenkinsClassnamePrefix: 'Server Pro E2E tests', - rootSuiteTitle: 'Server Pro E2E tests', - }, -} diff --git a/server-ce/test/cypress/support/e2e.ts b/server-ce/test/cypress/support/e2e.ts deleted file mode 100644 index f8f1eac89c..0000000000 --- a/server-ce/test/cypress/support/e2e.ts +++ /dev/null @@ -1,12 +0,0 @@ -import '@testing-library/cypress/add-commands' - -Cypress.on('uncaught:exception', (err, runnable) => { - if (err.message.includes('ResizeObserver')) { - // spurious error from PDF preview - return false - } - if (err.message.includes('rcube_webmail')) { - // spurious error from mailtrap - return false - } -}) diff --git a/server-ce/test/docker-compose.native.yml b/server-ce/test/docker-compose.native.yml deleted file mode 100644 index 879dc37da7..0000000000 --- a/server-ce/test/docker-compose.native.yml +++ /dev/null @@ -1,48 +0,0 @@ -services: - sharelatex: - ports: - - "127.0.0.1:3231:80" - environment: - OVERLEAF_SITE_URL: "http://127.0.0.1:3231" - - host-admin: - ports: - - "127.0.0.1:3232:80" - environment: - NATIVE_CYPRESS: "true" - ACCESS_CONTROL_ALLOW_ORIGIN: "http://127.0.0.1:3231" - - saml: - ports: - - "127.0.0.1:3233:80" - environment: - SAML_BASE_URL_PATH: "http://127.0.0.1:3233/simplesaml/" - SAML_TEST_SP_LOCATION: "http://127.0.0.1:3231/saml/callback" - - mailtrap: - ports: - - "127.0.0.1:3234:80" - - # For testing "native" on Linux - cypress: - build: - context: ../../dockerfiles/cypress - args: - - USER_UID - - USER_GID - volumes: - - ../../:/overleaf - - /tmp/.X11-unix:/tmp/.X11-unix - - ${XAUTHORITY:-/dev/null}:/home/node/.Xauthority - working_dir: /overleaf/server-ce/test - entrypoint: npm - command: run cypress:open - environment: - CI: - VERBOSE_LOGGING: - DISPLAY: ${DISPLAY:-:0} - CYPRESS_BASE_URL: - CYPRESS_HOST_ADMIN_URL: - CYPRESS_SAML_URL: - CYPRESS_MAILTRAP_URL: - network_mode: host diff --git a/server-ce/test/docker-compose.yml b/server-ce/test/docker-compose.yml deleted file mode 100644 index 26da756b2b..0000000000 --- a/server-ce/test/docker-compose.yml +++ /dev/null @@ -1,158 +0,0 @@ -services: - sharelatex: - image: ${IMAGE_TAG_CE:-sharelatex/sharelatex:latest} - stop_grace_period: 0s - depends_on: - mongo: - condition: service_healthy - redis: - condition: service_started - mailtrap: - condition: service_started - environment: - OVERLEAF_SITE_URL: "http://sharelatex" - OVERLEAF_APP_NAME: E2E test - OVERLEAF_MONGO_URL: mongodb://mongo/sharelatex?directConnection=true - OVERLEAF_REDIS_HOST: redis - REDIS_HOST: redis - OVERLEAF_EMAIL_FROM_ADDRESS: "welcome@example.com" - OVERLEAF_EMAIL_SMTP_HOST: "mailtrap" - OVERLEAF_EMAIL_SMTP_PORT: "25" - OVERLEAF_EMAIL_SMTP_IGNORE_TLS: "true" - ENABLED_LINKED_FILE_TYPES: "project_file,project_output_file" - ENABLE_CONVERSIONS: "true" - EMAIL_CONFIRMATION_DISABLED: "true" - OVERLEAF_INVITE_TOKEN_SECRET: "invite-token-secret" - healthcheck: - test: curl --fail http://localhost:3000/status - interval: 3s - timeout: 3s - retries: 30 - - mailtrap: - image: mailtrap - environment: - MAILTRAP_PASSWORD: "password-for-mailtrap" - - mongo: - image: mongo:8.0.11 - command: "--replSet overleaf" - volumes: - - ../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js - - ../bin/shared/mongodb-docker-entrypoint-wait.sh:/mongodb-docker-entrypoint-wait.sh - entrypoint: /mongodb-docker-entrypoint-wait.sh - environment: - MONGO_INITDB_DATABASE: sharelatex - extra_hosts: - # Required when using the automatic database setup for initializing the replica set. - # This override is not needed when running the setup after starting up mongo. - - mongo:127.0.0.1 - healthcheck: - test: echo 'db.stats().ok' | mongosh localhost:27017/test --quiet - interval: 3s - timeout: 3s - retries: 30 - - redis: - image: redis:7.2.1 - - git-bridge: - image: quay.io/sharelatex/git-bridge:latest - environment: - GIT_BRIDGE_API_BASE_URL: "http://sharelatex:3000/api/v0/" # "http://sharelatex/api/v0/" for version 4.1.6 and earlier - GIT_BRIDGE_OAUTH2_SERVER: "http://sharelatex" - GIT_BRIDGE_POSTBACK_BASE_URL: "http://git-bridge:8000" - GIT_BRIDGE_ROOT_DIR: "/data/git-bridge" - user: root - command: ["/server-pro-start.sh"] - - e2e: - build: - context: ../../dockerfiles/cypress - args: - - USER_UID - - USER_GID - stop_grace_period: 0s - entrypoint: yarn - command: run cypress:run - # See comment in Makefile regarding matching file paths - working_dir: $PWD - volumes: - - $PWD:$PWD - - $MONOREPO/libraries:$MONOREPO/libraries - - $MONOREPO/node_modules:$MONOREPO/node_modules - - $MONOREPO/.yarn:$MONOREPO/.yarn - - $MONOREPO/.yarnrc.yml:$MONOREPO/.yarnrc.yml - - $MONOREPO/package.json:$MONOREPO/package.json - - $MONOREPO/yarn.lock:$MONOREPO/yarn.lock - environment: - MONOREPO: - CYPRESS_SHARD: - CYPRESS_BASE_URL: http://sharelatex - CYPRESS_FULL_FILESTORE_MIGRATION: - depends_on: - sharelatex: - condition: service_healthy - host-admin: - condition: service_healthy - - e2e-open: - extends: - service: e2e - command: run cypress:open - volumes: - - /tmp/.X11-unix:/tmp/.X11-unix - - ${XAUTHORITY:-/dev/null}:/home/node/.Xauthority - environment: - DISPLAY: ${DISPLAY:-:0} - - host-admin: - build: . - entrypoint: ["node", "--watch", "host-admin.js"] - # See comment in Makefile regarding matching file paths - working_dir: $PWD - volumes: - - $PWD:$PWD - - $MONOREPO/libraries:$MONOREPO/libraries:ro - - $MONOREPO/node_modules:$MONOREPO/node_modules:ro - - $MONOREPO/.yarn:$MONOREPO/.yarn:ro - - $MONOREPO/.yarnrc.yml:$MONOREPO/.yarnrc.yml:ro - - $MONOREPO/package.json:$MONOREPO/package.json:ro - - $MONOREPO/yarn.lock:$MONOREPO/yarn.lock:ro - - /var/run/docker.sock:/var/run/docker.sock - stop_grace_period: 0s - environment: - CI: - PWD: - MONOREPO: - CYPRESS_SHARD: - COMPOSE_PROJECT_NAME: - TEX_LIVE_DOCKER_IMAGE: - ALL_TEX_LIVE_DOCKER_IMAGES: - IMAGE_TAG_CE: - IMAGE_TAG_PRO: - IMAGE_TAG_GIT_BRIDGE: - healthcheck: - test: curl --fail http://localhost/status - interval: 3s - timeout: 3s - retries: 30 - - saml: - restart: always - image: us-east1-docker.pkg.dev/overleaf-ops/ol-docker/saml-test - environment: - SAML_TEST_SP_ENTITY_ID: "sharelatex-test-saml" - SAML_BASE_URL_PATH: "http://saml/simplesaml/" - SAML_TEST_SP_LOCATION: "http://sharelatex/saml/callback" - - ldap: - restart: always - image: rroemhild/test-openldap:1.1 - # Workaround large memory allocation (using the max-open-files-limit as socket buffer scale). - # REF: https://github.com/moby/moby/issues/8231#issuecomment-63877553 - # REF: https://github.com/moby/moby/issues/8231#issuecomment-63871343 - command: - - "bash" - - "-c" - - "ulimit -n 1024 && exec bash /run.sh" diff --git a/server-ce/test/editor.spec.ts b/server-ce/test/editor.spec.ts deleted file mode 100644 index 79013bdc87..0000000000 --- a/server-ce/test/editor.spec.ts +++ /dev/null @@ -1,451 +0,0 @@ -import { - createNewFile, - createProjectAndOpenInNewEditor, - openProjectById, - openProjectByName, - shareProjectByEmailAndAcceptInviteViaDash, - testNewFileUpload, -} from './helpers/project' -import { isExcludedBySharding, startWith } from './helpers/config' -import { ensureUserExists, login } from './helpers/login' -import { v4 as uuid } from 'uuid' -import { beforeWithReRunOnTestRetry } from './helpers/beforeWithReRunOnTestRetry' -import { prepareWaitForNextCompileSlot } from './helpers/compile' - -const USER = 'user@example.com' -const COLLABORATOR = 'collaborator@example.com' - -describe('editor', function () { - if (isExcludedBySharding('PRO_DEFAULT_1')) return - startWith({ pro: true }) - ensureUserExists({ email: USER }) - ensureUserExists({ email: COLLABORATOR }) - - let projectName: string - let projectId: string - let recompile: () => void - let waitForCompile: (fn: () => void) => void - beforeWithReRunOnTestRetry(() => { - projectName = `project-${uuid()}` - login(USER) - createProjectAndOpenInNewEditor(projectName, { - type: 'Example project', - }).then(id => (projectId = id)) - ;({ recompile, waitForCompile } = prepareWaitForNextCompileSlot()) - }) - - beforeEach(function () { - login(USER) - waitForCompile(() => { - openProjectById(projectId, true) - }) - }) - - describe('spelling', function () { - function changeSpellCheckLanguageTo(lng: string) { - cy.log(`change project language to '${lng}'`) - cy.findByRole('button', { name: 'Settings' }).click() - cy.findByRole('dialog').within(() => { - cy.findByRole('tab', { name: 'Spelling and language' }).click() - cy.findByLabelText('Spellcheck language').select(lng) - }) - cy.get('body').type('{esc}') - } - - afterEach(function () { - changeSpellCheckLanguageTo('Off') - }) - - it('word dictionary and spelling', function () { - changeSpellCheckLanguageTo('English (American)') - createNewFile() - const word = createRandomLetterString() - - cy.log('edit project file') - cy.get('.cm-line').type(word) - cy.findByText(word).should('have.class', 'ol-cm-spelling-error') - - changeSpellCheckLanguageTo('Off') - cy.findByText(word).should('not.have.class', 'ol-cm-spelling-error') - - changeSpellCheckLanguageTo('Spanish') - cy.findByText(word).should('have.class', 'ol-cm-spelling-error') - - cy.log('add word to dictionary') - cy.findByText(word).rightclick() - cy.findByRole('menuitem', { name: 'Add to dictionary' }).click() - cy.findByText(word).should('not.have.class', 'ol-cm-spelling-error') - - cy.log('remove word from dictionary') - cy.findByRole('button', { name: 'Settings' }).click() - cy.findByRole('dialog').within(() => { - cy.findByRole('tab', { name: 'Spelling and language' }).click() - cy.findByLabelText('Dictionary').click() - }) - cy.findByTestId('dictionary-modal').within(() => { - cy.findByText(word) - .parent() - .within(() => - cy.findByRole('button', { name: 'Remove from dictionary' }).click() - ) - - cy.findByRole('button', { name: 'Close dialog' }).click() - }) - - cy.log('close modal') - cy.get('body').type('{esc}') - - cy.log('rewrite word to force spelling error') - cy.get('.cm-line').type('{selectAll}{del}' + word + '{enter}') - - cy.get('.ol-cm-spelling-error').should('contain.text', word) - }) - }) - - describe('editor', function () { - it('renders jpg', function () { - cy.findByRole('treeitem', { name: 'frog.jpg' }).click({ force: true }) - - cy.get('[alt="frog.jpg"]') - .should('be.visible') - .and('have.prop', 'naturalWidth') - .should('be.greaterThan', 0) - }) - - it('symbol palette', function () { - createNewFile() - - cy.get('button[aria-label="Insert symbol"]').click({ - force: true, - }) - cy.get('button').contains('𝜉').click() - cy.findByRole('textbox', { name: 'Source Editor editing' }).should( - 'contain.text', - '\\xi' - ) - - cy.log('recompile to force flush and avoid "unsaved changes" prompt') - recompile() - }) - }) - - describe('add new file to project', function () { - beforeEach(function () { - cy.findByRole('button', { name: 'New file' }).click() - }) - - testNewFileUpload() - - it('should not display import from URL', function () { - cy.findByRole('button', { name: 'From external URL' }).should('not.exist') - }) - }) - - describe('file menu', function () { - it('can download project sources', function () { - cy.findByRole('button', { name: 'File' }).click() - cy.findByRole('menuitem', { name: 'Download' }).click() - cy.findByRole('menuitem', { name: 'Download as source (.zip)' }).click() - const zipName = projectName.replaceAll('-', '_') - cy.task('readFileInZip', { - pathToZip: `cypress/downloads/${zipName}.zip`, - fileToRead: 'main.tex', - }).should('contain', 'Your introduction goes here') - }) - - it('can download project PDF', function () { - cy.log('ensure project is compiled') - cy.findByRole('region', { name: 'PDF preview' }).should( - 'contain.text', - 'Your Paper' - ) - - cy.findByRole('button', { name: 'File' }).click() - cy.findByRole('menuitem', { name: 'Download' }).click() - cy.findByRole('menuitem', { name: 'Download as PDF' }).click() - const pdfName = projectName.replaceAll('-', '_') - cy.task('readPdf', `cypress/downloads/${pdfName}.pdf`).should( - 'contain', - 'Your introduction goes here' - ) - }) - - it('word count', function () { - cy.log('ensure project is compiled') - cy.findByRole('region', { name: 'PDF preview' }).should( - 'contain.text', - 'Your Paper' - ) - - cy.findByRole('button', { name: 'File' }).click() - cy.findByRole('menuitem', { name: 'Word count' }).click() - - cy.findByTestId('word-count-modal').within(() => { - cy.findByText('Total Words:') - cy.findByText('607') - cy.findByText('Headers:') - cy.findByText('14') - cy.findByText('Math Inline:') - cy.findByText('6') - cy.findByText('Math Display:') - cy.findByText('1') - }) - }) - }) - - describe('cite key search', function () { - it('can insert citation from cite key', function () { - createNewFile() - cy.get('.cm-line').type('\\cite{{}gre') - cy.findByRole('listbox').within(() => { - cy.findByRole('option').should('contain.text', 'greenwade93').click() - }) - cy.get('.cm-line').should('have.text', '\\cite{greenwade93}') - }) - - it('updates citation search when bib file is changed', function () { - createNewFile() - cy.get('.cm-line').type('\\cite{{}new') - // Wait a reasonable time to ensure the autocomplete would've appeared if there were any matches - // eslint-disable-next-line cypress/no-unnecessary-waiting - cy.wait(200) - cy.findByRole('listbox').should('not.exist') - cy.findByRole('treeitem', { name: 'sample.bib' }).click() - cy.get('.cm-line') - .last() - .type( - '\n@article{{}newkey2024,\n author = {{}Doe, John},\n title = {{}A New Article},\n journal = {{}Journal of Testing},\n year = 2024\n}\n' - ) - createNewFile() - cy.get('.cm-line').type('\\cite{{}new') - cy.findByRole('listbox').within(() => { - cy.findByRole('option').should('contain.text', 'newkey2024').click() - }) - cy.get('.cm-line').should('have.text', '\\cite{newkey2024}') - }) - }) - - describe('layout selector', function () { - it('show editor only and switch between editor and pdf', function () { - cy.findByRole('region', { name: 'PDF preview' }).should('be.visible') - cy.get('.cm-editor').should('be.visible') - - cy.findByRole('button', { name: 'Layout options' }).click() - cy.findByRole('menu').within(() => { - cy.findByRole('menuitem', { name: /Editor only/ }).click() - }) - - cy.findByRole('region', { name: 'PDF preview' }).should('not.be.visible') - cy.get('.cm-editor').should('be.visible') - - // force click as tooltip may cover button for some reason - cy.findByRole('button', { name: 'Switch to PDF' }).click({ force: true }) - - cy.findByRole('region', { name: 'PDF preview' }).should('be.visible') - cy.get('.cm-editor').should('not.be.visible') - - cy.findByRole('button', { name: 'Switch to editor' }).click() - - cy.findByRole('region', { name: 'PDF preview' }).should('not.be.visible') - cy.get('.cm-editor').should('be.visible') - }) - - it('show PDF only and go back to split view', function () { - cy.findByRole('region', { name: 'PDF preview' }).should('be.visible') - cy.get('.cm-editor').should('be.visible') - - cy.findByRole('button', { name: 'Layout options' }).click() - cy.findByRole('menu').within(() => { - cy.findByRole('menuitem', { name: /PDF only/ }).click() - }) - - cy.findByRole('region', { name: 'PDF preview' }).should('be.visible') - cy.get('.cm-editor').should('not.be.visible') - - cy.findByRole('button', { name: 'Layout options' }).click() - cy.findByRole('menu').within(() => { - cy.findByRole('menuitem', { name: 'Split view' }).click() - }) - - cy.findByRole('region', { name: 'PDF preview' }).should('be.visible') - cy.get('.cm-editor').should('be.visible') - }) - - it('PDF in a separate tab (tests editor only)', function () { - cy.findByTestId('pdf-viewer').should('be.visible') - cy.get('.cm-editor').should('be.visible') - - cy.findByRole('button', { name: 'Layout options' }).click() - cy.findByRole('menu').within(() => { - cy.findByRole('menuitem', { name: 'Open PDF in separate tab' }).click() - }) - - cy.findByTestId('pdf-viewer').should('not.exist') - cy.get('.cm-editor').should('be.visible') - }) - }) - - describe('full project search', function () { - it('can search for text in project files', function () { - cy.findByRole('tab', { name: 'Project search' }).click() - - cy.findByRole('searchbox', { name: 'Search' }) - .should('be.visible') - .type('Some examples to get started') - cy.get('button').contains('Search').click() - - cy.findByRole('listbox').within(() => { - cy.findByRole('option', { - name: /Some examples to get started/, - }).should('be.visible') - }) - }) - }) - - describe('theming', function () { - it('can change overall theme in settings menu', function () { - cy.findByRole('button', { name: 'Settings' }).click() - cy.findByRole('dialog').within(() => { - cy.findByRole('tab', { name: 'Appearance' }).click() - cy.findByLabelText('Overall theme').select('Dark') - }) - cy.get('body').should('have.attr', 'data-theme', 'default') - cy.get('body').type('{esc}') - - cy.findByRole('button', { name: 'Settings' }).click() - cy.findByRole('dialog').within(() => { - cy.findByRole('tab', { name: 'Appearance' }).click() - cy.findByLabelText('Overall theme').select('Light') - }) - cy.get('body').should('have.attr', 'data-theme', 'light') - cy.get('body').type('{esc}') - }) - }) -}) - -describe('editing mode', function () { - if (isExcludedBySharding('PRO_DEFAULT_1')) return - startWith({ pro: true }) - - const OWNER = USER - const EDITOR = COLLABORATOR - const REVIEWER = 'reviewer@example.com' - const VIEWER = 'viewer@example.com' - ensureUserExists({ email: OWNER }) - ensureUserExists({ email: EDITOR }) - ensureUserExists({ email: REVIEWER }) - ensureUserExists({ email: VIEWER }) - - let projectName: string - - function openEditingModeMenu() { - cy.findByRole('button', { name: 'View' }).click() - cy.findByRole('menuitem', { name: 'Editing mode' }).click() - } - - beforeWithReRunOnTestRetry(() => { - projectName = `project-${uuid()}` - login(OWNER) - createProjectAndOpenInNewEditor(projectName, { type: 'Example project' }) - - shareProjectByEmailAndAcceptInviteViaDash( - projectName, - EDITOR, - 'Editor', - true - ) - login(OWNER) - shareProjectByEmailAndAcceptInviteViaDash( - projectName, - REVIEWER, - 'Reviewer', - true - ) - login(OWNER) - shareProjectByEmailAndAcceptInviteViaDash( - projectName, - VIEWER, - 'Viewer', - true - ) - }) - - it('lets an editor switch between editing and reviewing', function () { - login(EDITOR) - openProjectByName(projectName, true) - - openEditingModeMenu() - cy.findByRole('menuitem', { name: 'Editing' }) - .should('have.attr', 'aria-selected', 'true') - .and('not.have.attr', 'aria-disabled') - cy.findByRole('menuitem', { name: 'Reviewing' }) - .should('have.attr', 'aria-selected', 'false') - .and('not.have.attr', 'aria-disabled') - cy.findByRole('menuitem', { name: 'Viewing' }).should('not.exist') - - cy.findByRole('menuitem', { name: 'Reviewing' }).click() - cy.findByRole('button', { name: 'Reviewing' }).should('exist') - - openEditingModeMenu() - cy.findByRole('menuitem', { name: 'Reviewing' }).should( - 'have.attr', - 'aria-selected', - 'true' - ) - cy.findByRole('menuitem', { name: 'Editing' }).should( - 'have.attr', - 'aria-selected', - 'false' - ) - - cy.findByRole('menuitem', { name: 'Editing' }).click() - cy.findByRole('button', { name: 'Editing' }).should('exist') - }) - - it('shows a reviewer in reviewing mode with editing disabled', function () { - login(REVIEWER) - openProjectByName(projectName, true) - - openEditingModeMenu() - cy.findByRole('menuitem', { name: 'Reviewing' }) - .should('have.attr', 'aria-selected', 'true') - .and('not.have.attr', 'aria-disabled') - cy.findByRole('menuitem', { name: 'Editing' }).should( - 'have.attr', - 'aria-disabled', - 'true' - ) - cy.findByRole('menuitem', { name: 'Viewing' }).should('not.exist') - }) - - it('shows a viewer in viewing mode with editing and reviewing disabled', function () { - login(VIEWER) - openProjectByName(projectName, true) - - openEditingModeMenu() - cy.findByRole('menuitem', { name: 'Viewing' }).should( - 'have.attr', - 'aria-selected', - 'true' - ) - cy.findByRole('menuitem', { name: 'Editing' }).should( - 'have.attr', - 'aria-disabled', - 'true' - ) - cy.findByRole('menuitem', { name: 'Reviewing' }).should( - 'have.attr', - 'aria-disabled', - 'true' - ) - }) -}) - -function createRandomLetterString() { - const chars = 'abcdefghijklmnopqrstuvwxyz' - let result = '' - for (let i = 0; i < 12; i++) { - result += chars.charAt(Math.floor(Math.random() * chars.length)) - } - return result -} diff --git a/server-ce/test/external-auth.spec.ts b/server-ce/test/external-auth.spec.ts deleted file mode 100644 index 8c287f2543..0000000000 --- a/server-ce/test/external-auth.spec.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { isExcludedBySharding, startWith } from './helpers/config' -import { createProject } from './helpers/project' - -describe('SAML', function () { - if (isExcludedBySharding('PRO_CUSTOM_1')) return - const samlURL = Cypress.env('SAML_URL') || 'http://saml' - - startWith({ - pro: true, - vars: { - EXTERNAL_AUTH: 'saml', - OVERLEAF_SAML_ENTRYPOINT: `${samlURL}/simplesaml/saml2/idp/SSOService.php`, - OVERLEAF_SAML_CALLBACK_URL: `${Cypress.config().baseUrl}/saml/callback`, - OVERLEAF_SAML_ISSUER: 'sharelatex-test-saml', - OVERLEAF_SAML_IDENTITY_SERVICE_NAME: 'SAML Test Server', - OVERLEAF_SAML_EMAIL_FIELD: 'email', - OVERLEAF_SAML_FIRST_NAME_FIELD: 'givenName', - OVERLEAF_SAML_LAST_NAME_FIELD: 'sn', - OVERLEAF_SAML_UPDATE_USER_DETAILS_ON_LOGIN: 'true', - OVERLEAF_SAML_CERT: - 'MIIDXTCCAkWgAwIBAgIJAOvOeQ4xFTzsMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNVBAYTAkdCMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMTYxMTE1MTQxMjU5WhcNMjYxMTE1MTQxMjU5WjBFMQswCQYDVQQGEwJHQjETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxCT6MBe5G9VoLU8MfztOEbUhnwLp17ak8eFUqxqeXkkqtWB0b/cmIBU3xoQoO3dIF8PBzfqehqfYVhrNt/TFgcmDfmJnPJRL1RJWMW3VmiP5odJ3LwlkKbZpkeT3wZ8HEJIR1+zbpxiBNkbd2GbdR1iumcsHzMYX1A2CBj+ZMV5VijC+K4P0e9c05VsDEUtLmfeAasJAiumQoVVgAe/BpiXjICGGewa6EPFI7mKkifIRKOGxdRESwZZjxP30bI31oDN0cgKqIgSJtJ9nfCn9jgBMBkQHu42WMuaWD4jrGd7+vYdX+oIfArs9aKgAH5kUGhGdew2R9SpBefrhbNxG8QIDAQABo1AwTjAdBgNVHQ4EFgQU+aSojSyyLChP/IpZcafvSdhj7KkwHwYDVR0jBBgwFoAU+aSojSyyLChP/IpZcafvSdhj7KkwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEABl3+OOVLBWMKs6PjA8lPuloWDNzSr3v76oUcHqAb+cfbucjXrOVsS9RJ0X9yxvCQyfM9FfY43DbspnN3izYhdvbJD8kKLNf0LA5st+ZxLfy0ACyL2iyAwICaqndqxAjQYplFAHmpUiu1DiHckyBPekokDJd+ze95urHMOsaGS5RWPoKJVE0bkaAeZCmEu0NNpXRSBiuxXSTeSAJfv6kyE/rkdhzUKyUl/cGQFrsVYfAFQVA+W6CKOh74ErSEzSHQQYndl7nD33snD/YqdU1ROxV6aJzLKCg+sdj+wRXSP2u/UHnM4jW9TGJfhO42jzL6WVuEvr9q4l7zWzUQKKKhtQ==', - }, - }) - - it('login', function () { - cy.visit('/') - cy.findByRole('link', { name: 'Log in with SAML Test Server' }).click() - - cy.origin(samlURL, () => { - cy.get('input[name="username"]').type('sally') - cy.get('input[name="password"]').type('sally123') - cy.get('button[type="submit"]').click() - }) - - cy.log('wait for login to finish') - cy.url().should('contain', '/project') - - createProject('via SAML') - }) -}) - -describe('LDAP', function () { - if (isExcludedBySharding('PRO_CUSTOM_1')) return - startWith({ - pro: true, - vars: { - EXTERNAL_AUTH: 'ldap', - OVERLEAF_LDAP_URL: 'ldap://ldap:389', - OVERLEAF_LDAP_SEARCH_BASE: 'ou=people,dc=planetexpress,dc=com', - OVERLEAF_LDAP_SEARCH_FILTER: '(uid={{username}})', - OVERLEAF_LDAP_BIND_DN: 'cn=admin,dc=planetexpress,dc=com', - OVERLEAF_LDAP_BIND_CREDENTIALS: 'GoodNewsEveryone', - OVERLEAF_LDAP_EMAIL_ATT: 'mail', - OVERLEAF_LDAP_NAME_ATT: 'cn', - OVERLEAF_LDAP_LAST_NAME_ATT: 'sn', - OVERLEAF_LDAP_UPDATE_USER_DETAILS_ON_LOGIN: 'true', - }, - }) - - it('login', function () { - cy.visit('/') - cy.findByRole('heading', { name: 'Log in LDAP' }) - - cy.findByLabelText('Username').type('fry') - cy.findByLabelText('Password').type('fry') - cy.findByRole('button', { name: 'Login' }).click() - - cy.log('wait for login to finish') - cy.url().should('contain', '/project') - - createProject('via LDAP') - }) -}) diff --git a/server-ce/test/filestore-migration.spec.ts b/server-ce/test/filestore-migration.spec.ts deleted file mode 100644 index 3693d4a362..0000000000 --- a/server-ce/test/filestore-migration.spec.ts +++ /dev/null @@ -1,433 +0,0 @@ -import { DEFAULT_PASSWORD, ensureUserExists, login } from './helpers/login' -import { - createProject, - expectFileExists, - openProjectById, - prepareFileUploadTest, -} from './helpers/project' -import { isExcludedBySharding, startWith } from './helpers/config' -import { prepareWaitForNextCompileSlot } from './helpers/compile' -import { v4 as uuid } from 'uuid' -import { - purgeFilestoreData, - runGruntTask, - runScript, - setMongoFeatureCompatibilityVersion, -} from './helpers/hostAdminClient' - -function activateUserVersion1x(url: string, password = DEFAULT_PASSWORD) { - cy.session(url, () => { - cy.visit(url) - cy.url().then(url => { - if (url.includes('/login')) return - cy.url().should('contain', '/user/password/set') - cy.get('input[type="password"]').type(password) - cy.findByRole('button', { name: 'Set new password' }).click() - }) - }) -} - -describe('filestore migration', function () { - if (isExcludedBySharding('PRO_CUSTOM_5')) return - const email = 'user@example.com' - // Branding of env vars changed in 5.x - const sharelatexBrandedVars = { - SHARELATEX_SITE_URL: 'http://sharelatex', - SHARELATEX_MONGO_URL: 'mongodb://mongo/sharelatex', - SHARELATEX_REDIS_HOST: 'redis', - } - const projectName = `project-${uuid()}` - let defaultImage: string - let projectId: string - let waitForCompile: (fn: () => void) => void - const previousBinaryFiles: (() => void)[] = [] - - function avoid502() { - // The next step will likely restart the instance and any following - // requests will fail with a 502/bad gateway. Avoid this by navigating - // away from the editor, which will reload upon receiving a - // 'forceDisconnect' socket.io message. - cy.visit('/project') - } - - function addNewBinaryFileAndCheckPrevious( - universeSelector = `img[alt="${defaultImage}"]` - ) { - before(function () { - login(email) - waitForCompile(() => { - cy.visit(`/project/${projectId}`) - }) - previousBinaryFiles.push(prepareFileUploadTest(true)) - cy.log('check binary files') - for (const check of previousBinaryFiles) { - check() - } - cy.findByRole('treeitem', { name: defaultImage }).click() - cy.get(universeSelector) - .should('be.visible') - .and('have.prop', 'naturalWidth') - .should('be.greaterThan', 0) - - avoid502() - }) - } - - if (Cypress.env('FULL_FILESTORE_MIGRATION')) { - // -------------- - // Server Pro 1.x - startWith({ - pro: true, - resetData: true, - withDataDir: true, - vars: sharelatexBrandedVars, - version: '1.2.4', - mongoVersion: '5.0', - }) - defaultImage = 'universe.jpg' - - let activateURL: string - before(async function () { - const { stdout } = await runGruntTask({ - task: 'user:create-admin', - args: ['--email', email], - }) - ;[activateURL] = stdout.match( - /http:\/\/.+\/user\/password\/set\?passwordResetToken=\S+/ - )! - }) - before(function () { - activateUserVersion1x(activateURL) - login(email) - cy.visit('/project') - - // Legacy angular based UI uses links instead of buttons - cy.findByRole('link', { - name: /Create First Project|New Project/, - }).click() - cy.findByRole('link', { name: 'Example Project' }).click() - cy.findByLabelText('Project name').type(projectName) - cy.findByRole('button', { name: 'Create' }).click() - cy.url() - .should('match', /\/project\/[a-fA-F0-9]{24}/) - .then(url => (projectId = url.split('/').pop()!)) - let queueReset - ;({ waitForCompile, queueReset } = prepareWaitForNextCompileSlot()) - queueReset() - - // Create a new binary file - cy.get(`a[tooltip="Upload"]`).click() - const name = `${uuid()}.txt` - // Binary file detection is not sophisticated in version 1.x - const binName = name.replace('.txt', '.bin') - const content = `Test File Content ${name} \x00` - cy.get('input[type=file]') - .first() - .selectFile( - { - contents: Cypress.Buffer.from(content), - fileName: binName, - lastModified: Date.now(), - }, - { force: true } - ) - // Rename back to .txt to enable preview - cy.findByText(binName).click() - cy.findByText(binName).dblclick() - cy.focused().type(name + '{del}'.repeat('.bin'.length) + '{enter}') - // Switch back and forth - cy.findByText('universe.jpg').click() - cy.findByText(name).click() - cy.findByText(content) - .parent() - .parent() - .should('have.class', 'text-preview') - - previousBinaryFiles.push(() => expectFileExists(name, true, content)) - avoid502() - }) - - // -------------- - // Server Pro 2.x - startWith({ - pro: true, - withDataDir: true, - vars: sharelatexBrandedVars, - version: '2.7.1', - mongoVersion: '5.0', - }) - before(function () { - // Cypress strips the Content-Length header: https://github.com/cypress-io/cypress/issues/16469 - // Server Pro 2.x does not gracefully handle a missing value. - cy.intercept( - { - method: 'HEAD', - url: `http://sharelatex/project/${projectId}/file/*`, - times: previousBinaryFiles.length + 1, - }, - req => { - req.continue(res => { - res.headers['Content-Length'] = '60' - }) - } - ) - }) - // Server Pro 2.x does not have alt tags on images. - addNewBinaryFileAndCheckPrevious('img') - - // ---------------------------------- - // Server Pro 3.x + history migration - startWith({ - pro: true, - withDataDir: true, - vars: sharelatexBrandedVars, - version: '3.5.13', - mongoVersion: '5.0', - }) - addNewBinaryFileAndCheckPrevious() // before history migration - before(async function () { - await runScript({ - cwd: 'services/web', - script: 'scripts/history/migrate_history.js', - args: [ - '--force-clean', - '--fix-invalid-characters', - '--convert-large-docs-to-file', - ], - hasOverleafEnv: false, - user: 'root', - }) - }) - before(async function () { - await runScript({ - cwd: 'services/web', - script: 'scripts/history/clean_sl_history_data.js', - hasOverleafEnv: false, - }) - }) - addNewBinaryFileAndCheckPrevious() // after history migration - - // ------------------------------ - // Server Pro 4.x + mongo upgrade - startWith({ - pro: true, - withDataDir: true, - vars: sharelatexBrandedVars, - version: '4.2.9', - mongoVersion: '5.0', - }) - startWith({ - pro: true, - withDataDir: true, - vars: sharelatexBrandedVars, - version: '4.2.9', - mongoVersion: '6.0', - }) - before(async function () { - await setMongoFeatureCompatibilityVersion('6.0') - }) - addNewBinaryFileAndCheckPrevious() - - // ------------------------------------------ - // Server Pro 5.x + mongo upgrade 6 -> 7 -> 8 - startWith({ - version: '5.5.5', - pro: true, - withDataDir: true, - mongoVersion: '6.0', - }) - startWith({ - version: '5.5.5', - pro: true, - withDataDir: true, - mongoVersion: '7.0', - }) - before(async function () { - await setMongoFeatureCompatibilityVersion('7.0') - }) - startWith({ - version: '5.5.5', - pro: true, - withDataDir: true, - // implicit mongo upgrade to 8.0 - }) - before(async function () { - await setMongoFeatureCompatibilityVersion('8.0') - }) - } else { - // 5.x - startWith({ - version: '5.5.5', - pro: true, - withDataDir: true, - resetData: true, - }) - defaultImage = 'frog.jpg' - ensureUserExists({ email }) - before(function () { - login(email) - createProject(projectName, { type: 'Example project', open: false }).then( - id => (projectId = id) - ) - ;({ waitForCompile } = prepareWaitForNextCompileSlot()) - }) - } - addNewBinaryFileAndCheckPrevious() - - function ensureStopOnFirstErrorIsActive() { - cy.findByRole('button', { name: 'Toggle compile options menu' }).click() - cy.findByRole('menuitem', { - name: 'Stop on first error', - }).then(el => { - // NOTE: THIS IS BAD, but the selected option is otherwise not accessible :/ - if ( - el.get()[0]?.querySelector('.material-symbol')?.textContent !== 'check' - ) { - cy.findByRole('menuitem', { - name: 'Stop on first error', - }).click() - // Clicking on "Stop on first error" closes the mode. Open it again. - cy.findByRole('button', { name: 'Toggle compile options menu' }).click() - } - }) - cy.findByRole('menuitem', { - name: 'Stop on first error', - }).within(() => { - cy.findByText('check').should('be.visible') - }) - cy.findByRole('button', { name: 'Toggle compile options menu' }).click() - } - - // ------------------- - // filestore-migration - beforeEach(function () { - login(email) - waitForCompile(() => { - openProjectById(projectId) - }) - ensureStopOnFirstErrorIsActive() - }) - - function checkFilesAreAccessible() { - it('can upload new binary file and read previous uploads', function () { - previousBinaryFiles.push(prepareFileUploadTest(true)) - for (const check of previousBinaryFiles) { - check() - } - }) - - it('renders image of example project', function () { - cy.findByTestId('file-tree').findByText(defaultImage).click() - cy.get(`[alt="${defaultImage}"]`) - .should('be.visible') - .and('have.prop', 'naturalWidth') - .should('be.greaterThan', 0) - }) - - it('can recompile from scratch', function () { - const id = uuid() - cy.findByText('\\maketitle').parent().click() - cy.findByText('\\maketitle') - .parent() - .type(`\n\\section{{}Test Section ${id}}`) - - waitForCompile(() => { - cy.findByRole('button', { name: 'Toggle compile options menu' }).click() - - cy.findByRole('menuitem', { - name: 'Recompile from scratch', - }).trigger('click') - }) - - cy.get('.pdf-viewer').should('contain.text', `Test Section ${id}`) - }) - } - - describe('OVERLEAF_FILESTORE_MIGRATION_LEVEL not set', function () { - startWith({ version: '5.5.5', pro: true, withDataDir: true, vars: {} }) - checkFilesAreAccessible() - }) - - describe('OVERLEAF_FILESTORE_MIGRATION_LEVEL=0', function () { - startWith({ - version: '5.5.5', - pro: true, - withDataDir: true, - vars: { OVERLEAF_FILESTORE_MIGRATION_LEVEL: '0' }, - }) - checkFilesAreAccessible() - - describe('OVERLEAF_FILESTORE_MIGRATION_LEVEL=1', function () { - startWith({ - version: '5.5.5', - pro: true, - withDataDir: true, - vars: { OVERLEAF_FILESTORE_MIGRATION_LEVEL: '1' }, - }) - checkFilesAreAccessible() - - describe('OVERLEAF_FILESTORE_MIGRATION_LEVEL=2', function () { - startWith({ - version: '5.5.5', - pro: true, - withDataDir: true, - vars: { OVERLEAF_FILESTORE_MIGRATION_LEVEL: '1' }, - }) - before(async function () { - await runScript({ - cwd: 'services/history-v1', - script: 'storage/scripts/back_fill_file_hash.mjs', - args: ['--all'], - }) - }) - startWith({ - version: '5.5.5', - pro: true, - withDataDir: true, - vars: { OVERLEAF_FILESTORE_MIGRATION_LEVEL: '2' }, - }) - checkFilesAreAccessible() - - describe('purge filestore data', function () { - before(async function () { - const deleted = await purgeFilestoreData() - expect(deleted).to.have.length.greaterThan( - previousBinaryFiles.length - ) - expect(deleted).to.include( - "removed directory '/var/lib/overleaf/data/user_files'" - ) - }) - checkFilesAreAccessible() - - describe('after next restart', function () { - startWith({ - version: '5.5.5', - pro: true, - withDataDir: true, - vars: { - OVERLEAF_APP_NAME: 'change-config', - OVERLEAF_FILESTORE_MIGRATION_LEVEL: '2', - }, - }) - it('should not recreate the user_files folder', async function () { - expect(await purgeFilestoreData()).to.deep.equal([]) - }) - }) - - describe('latest', function () { - startWith({ - pro: true, - withDataDir: true, - vars: { OVERLEAF_FILESTORE_MIGRATION_LEVEL: '2' }, - }) - it('should not recreate the user_files folder', async function () { - expect(await purgeFilestoreData()).to.deep.equal([]) - }) - checkFilesAreAccessible() - }) - }) - }) - }) - }) -}) diff --git a/server-ce/test/git-bridge.spec.ts b/server-ce/test/git-bridge.spec.ts deleted file mode 100644 index 7c8d8c47a2..0000000000 --- a/server-ce/test/git-bridge.spec.ts +++ /dev/null @@ -1,458 +0,0 @@ -import { v4 as uuid } from 'uuid' -import { isExcludedBySharding, startWith } from './helpers/config' -import { ensureUserExists, login } from './helpers/login' -import { - createProject, - createProjectAndOpenInNewEditor, - enableLinkSharing, - openProjectByName, - openProjectViaLinkSharingAsUser, - shareProjectByEmailAndAcceptInviteViaDash, -} from './helpers/project' - -import git from 'isomorphic-git' -import http from 'isomorphic-git/http/web' -import LightningFS from '@isomorphic-git/lightning-fs' -import { prepareWaitForNextCompileSlot } from './helpers/compile' - -const USER = 'user@example.com' - -describe('git-bridge', function () { - const ENABLED_VARS = { - GIT_BRIDGE_ENABLED: 'true', - GIT_BRIDGE_HOST: 'git-bridge', - GIT_BRIDGE_PORT: '8000', - V1_HISTORY_URL: 'http://sharelatex:3100/api', - } - - function gitURL(projectId: string) { - const url = new URL(Cypress.config().baseUrl!) - url.username = 'git' - url.pathname = `/git/${projectId}` - return url - } - - describe('enabled in Server Pro', function () { - if (isExcludedBySharding('PRO_CUSTOM_1')) return - startWith({ - pro: true, - vars: ENABLED_VARS, - }) - ensureUserExists({ email: USER }) - - function clearAllTokens() { - cy.findAllByRole('button', { name: 'Remove' }) - .not('[disabled]') - .each($button => { - cy.wrap($button).click() - cy.findByRole('button', { name: 'Delete token' }).click() - }) - cy.findByRole('dialog').should('not.exist') - } - - function maybeClearAllTokens() { - cy.visit('/user/settings') - cy.findByRole('heading', { name: 'Git integration' }) - cy.findByRole('button', { - name: /Generate token|Add another token/i, - }).then(btn => { - if (btn.text() === 'Add another token') { - clearAllTokens() - } - }) - } - - beforeEach(function () { - login(USER) - }) - - it('should render the git-bridge UI in the settings', function () { - maybeClearAllTokens() - cy.visit('/user/settings') - cy.findByRole('heading', { name: 'Git integration' }) - cy.findByRole('button', { - name: 'Git integration Generate token', - }).click() - cy.findByRole('dialog').within(() => { - cy.findByLabelText('Git authentication token') - .contains(/olp_[a-zA-Z0-9]{16}/) - .then(el => el.text()) - .as('newToken') - cy.findByRole('button', { name: 'Close dialog' }).click() - }) - cy.get('@newToken').then(token => { - // There can be more than one token with the same prefix when retrying - cy.findAllByText( - `${token.slice(0, 'olp_1234'.length)}${'*'.repeat(12)}` - ).should('have.length.at.least', 1) - }) - cy.findByRole('button', { - name: 'Git integration Generate token', - }).should('not.exist') - cy.findByRole('button', { name: 'Add another token' }).should('exist') - clearAllTokens() - cy.findByRole('button', { - name: 'Git integration Generate token', - }).should('exist') - cy.findByRole('button', { name: 'Add another token' }).should('not.exist') - }) - - it('should render the git-bridge UI in the editor', function () { - maybeClearAllTokens() - createProjectAndOpenInNewEditor('git').as('projectId') - cy.findByRole('tab', { name: 'Integrations' }).click() - cy.findByText('Git clone this project.').click() - - cy.findByTestId('git-bridge-modal').within(() => { - cy.get('@projectId').then(id => { - cy.findByLabelText('Git clone project command').contains( - `git clone ${gitURL(id.toString())}` - ) - }) - cy.findByRole('button', { - name: 'Generate token', - }).click() - cy.findByLabelText('Git authentication token').contains( - /olp_[a-zA-Z0-9]{16}/ - ) - }) - - // Re-open - cy.url().then(url => cy.visit(url)) - cy.findByText('Git clone this project.').click() - - cy.findByTestId('git-bridge-modal').within(() => { - cy.get('@projectId').then(id => { - cy.findByLabelText('Git clone project command').contains( - `git clone ${gitURL(id.toString())}` - ) - }) - cy.findByRole('button', { - name: 'Generate token', - }).should('not.exist') - cy.findByText(/generate a new one in Account settings/) - cy.findByRole('link', { name: 'Go to settings' }) - .should('have.attr', 'target', '_blank') - .and('have.attr', 'href', '/user/settings') - }) - }) - - describe('git access', function () { - ensureUserExists({ email: 'collaborator-rw@example.com' }) - ensureUserExists({ email: 'collaborator-ro@example.com' }) - ensureUserExists({ email: 'collaborator-link-rw@example.com' }) - ensureUserExists({ email: 'collaborator-link-ro@example.com' }) - - let projectName: string - let recompile: () => void - let waitForCompile: (triggerCompile: () => void) => void - beforeEach(function () { - projectName = uuid() - createProject(projectName, { open: false }).as('projectId') - ;({ recompile, waitForCompile } = prepareWaitForNextCompileSlot()) - }) - - it('should expose r/w interface to owner', function () { - maybeClearAllTokens() - waitForCompile(() => { - openProjectByName(projectName, true) - }) - checkGitAccess('readAndWrite') - }) - - it('should expose r/w interface to invited r/w collaborator', function () { - shareProjectByEmailAndAcceptInviteViaDash( - projectName, - 'collaborator-rw@example.com', - 'Editor', - true - ) - maybeClearAllTokens() - waitForCompile(() => { - openProjectByName(projectName, true) - }) - checkGitAccess('readAndWrite') - }) - - it('should expose r/o interface to invited r/o collaborator', function () { - shareProjectByEmailAndAcceptInviteViaDash( - projectName, - 'collaborator-ro@example.com', - 'Viewer', - true - ) - maybeClearAllTokens() - waitForCompile(() => { - openProjectByName(projectName, true) - }) - checkGitAccess('readOnly') - }) - - it('should expose r/w interface to link-sharing r/w collaborator', function () { - openProjectByName(projectName, true) - enableLinkSharing().then(({ linkSharingReadAndWrite }) => { - const email = 'collaborator-link-rw@example.com' - login(email) - maybeClearAllTokens() - waitForCompile(() => { - openProjectViaLinkSharingAsUser( - linkSharingReadAndWrite, - projectName, - email, - true - ) - }) - checkGitAccess('readAndWrite') - }) - }) - - it('should expose r/o interface to link-sharing r/o collaborator', function () { - waitForCompile(() => { - openProjectByName(projectName, true) - }) - enableLinkSharing().then(({ linkSharingReadOnly }) => { - const email = 'collaborator-link-ro@example.com' - login(email) - maybeClearAllTokens() - waitForCompile(() => { - openProjectViaLinkSharingAsUser( - linkSharingReadOnly, - projectName, - email, - true - ) - }) - checkGitAccess('readOnly') - }) - }) - - function checkGitAccess(access: 'readOnly' | 'readAndWrite') { - cy.findByRole('tab', { name: 'Integrations' }).click() - cy.findByText('Git clone this project.').click() - - cy.get('@projectId').then(projectId => { - cy.findByTestId('git-bridge-modal').within(() => { - cy.findByLabelText('Git clone project command').contains( - `git clone ${gitURL(projectId.toString())}` - ) - cy.findByRole('heading', { name: 'Clone with Git' }) - cy.findByRole('button', { - name: 'Generate token', - }).click() - }) - cy.findByLabelText('Git authentication token') - .contains(/olp_[a-zA-Z0-9]{16}/) - .then(async tokenEl => { - const token = tokenEl.text() - - // close Git modal - cy.findByRole('button', { name: 'Close dialog' }).click() - cy.findByTestId('git-bridge-modal').should('not.exist') - // close the modal - cy.get('body').type('{esc}') - cy.findByTestId('left-menu').should('not.exist') - const fs = new LightningFS('fs') - const dir = `/${projectId}` - - async function readFile(path: string): Promise { - return await new Promise((resolve, reject) => { - fs.readFile(path, { encoding: 'utf8' }, (err, blob) => { - if (err) return reject(err) - resolve(blob as string) - }) - }) - } - - async function writeFile(path: string, data: string) { - return await new Promise((resolve, reject) => { - fs.writeFile(path, data, undefined, err => { - if (err) return reject(err) - resolve() - }) - }) - } - - const commonOptions = { - dir, - fs, - } - const url = gitURL(projectId.toString()) - url.username = '' // basic auth is specified separately. - const httpOptions = { - http, - url: url.toString(), - headers: { - Authorization: `Basic ${Buffer.from(`git:${token}`).toString('base64')}`, - }, - } - const authorOptions = { - author: { name: 'user', email: USER }, - committer: { name: 'user', email: USER }, - } - const mainTex = `${dir}/main.tex` - - // Clone - cy.then({ timeout: 10_000 }, async () => { - await git.clone({ - ...commonOptions, - ...httpOptions, - }) - }) - cy.findByText(/\\documentclass/) - .parent() - .parent() - .then(async editor => { - const onDisk = await readFile(mainTex) - expect(onDisk.replaceAll('\n', '')).to.equal(editor.text()) - }) - const text = ` -\\documentclass{article} -\\begin{document} -Hello world -\\end{document} -` - - // Make a change - cy.then(async () => { - await writeFile(mainTex, text) - await git.add({ - ...commonOptions, - filepath: 'main.tex', - }) - await git.commit({ - ...commonOptions, - ...authorOptions, - message: 'Swap main.tex', - }) - }) - - if (access === 'readAndWrite') { - // check history before push - cy.findByRole('navigation', { - name: 'Project actions', - }) - .findByRole('button', { name: 'History' }) - .click() - cy.findByText('(via Git)').should('not.exist') - cy.findAllByText('Back to editor').last().click() - cy.then(async () => { - await git.push({ - ...commonOptions, - ...httpOptions, - }) - }) - } else { - cy.then(async () => { - try { - await git.push({ - ...commonOptions, - ...httpOptions, - }) - expect.fail('push should have failed') - } catch (err) { - expect(err).to.match(/branches were not updated/) - expect(err).to.match(/forbidden/) - } - }) - - return // return early, below are write access bits - } - - // check push in editor - cy.findByText(/\\documentclass/) - .parent() - .parent() - .should('have.text', text.replaceAll('\n', '')) - - // Wait for history sync - trigger flush by toggling the UI - cy.findByRole('navigation', { - name: 'Project actions', - }) - .findByRole('button', { name: 'History' }) - .click() - cy.findAllByText('Back to editor').last().click() - - // check push in history - cy.findByRole('navigation', { - name: 'Project actions', - }) - .findByRole('button', { name: 'History' }) - .click() - cy.findByText(/Hello world/) - cy.findByText('(via Git)').should('exist') - - // Back to the editor - cy.findAllByText('Back to editor').last().click() - cy.findByText(/\\documentclass/) - .parent() - .parent() - .as('editor') - .click() - cy.get('@editor').type('% via editor{enter}') - - // Trigger flush via compile - recompile() - - // Back into the history, check what we just added - cy.findByRole('navigation', { - name: 'Project actions', - }) - .findByRole('button', { name: 'History' }) - .click() - cy.findByText(/% via editor/) - - // Pull the change - cy.then(async () => { - await git.pull({ - ...commonOptions, - ...httpOptions, - ...authorOptions, - }) - - expect(await readFile(mainTex)).to.equal( - text + '% via editor\n' - ) - }) - }) - }) - } - }) - }) - - function checkDisabled() { - ensureUserExists({ email: USER }) - - it('should not render the git-bridge UI in the settings', function () { - login(USER) - cy.visit('/user/settings') - cy.findByRole('heading', { name: 'Git integration' }).should('not.exist') - }) - it('should not render the git-bridge UI in the editor', function () { - login(USER) - createProjectAndOpenInNewEditor('maybe git') - cy.findByRole('tab', { - name: 'File tree', - }).should('exist') // Wait for load - cy.findByRole('tab', { - name: 'Integrations', - }).should('not.exist') - }) - } - - describe('disabled in Server Pro', function () { - if (isExcludedBySharding('PRO_DEFAULT_1')) return - startWith({ - pro: true, - }) - checkDisabled() - }) - - describe('unavailable in CE', function () { - if (isExcludedBySharding('CE_CUSTOM_1')) return - startWith({ - pro: false, - vars: ENABLED_VARS, - }) - checkDisabled() - }) -}) diff --git a/server-ce/test/graceful-shutdown.spec.ts b/server-ce/test/graceful-shutdown.spec.ts deleted file mode 100644 index 11c8d518c1..0000000000 --- a/server-ce/test/graceful-shutdown.spec.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { ensureUserExists, login } from './helpers/login' -import { - isExcludedBySharding, - STARTUP_TIMEOUT, - startWith, -} from './helpers/config' -import { dockerCompose, getRedisKeys } from './helpers/hostAdminClient' -import { createProjectAndOpenInNewEditor } from './helpers/project' -import { prepareWaitForNextCompileSlot } from './helpers/compile' - -const USER = 'user@example.com' -const PROJECT_NAME = 'Old Project' - -function bringServerProBackUp() { - cy.log('bring server pro back up') - cy.then({ timeout: STARTUP_TIMEOUT }, async () => { - await dockerCompose('up', '--detach', '--wait', 'sharelatex') - }) -} - -describe('GracefulShutdown', function () { - if (isExcludedBySharding('PRO_CUSTOM_1')) return - startWith({ - pro: true, - withDataDir: true, - resetData: true, - }) - ensureUserExists({ email: USER }) - - let projectId: string - it('should display banner and flush changes out of redis', function () { - bringServerProBackUp() - login(USER) - const { recompile, waitForCompile } = prepareWaitForNextCompileSlot() - waitForCompile(() => { - createProjectAndOpenInNewEditor(PROJECT_NAME).then(id => { - projectId = id - }) - }) - - cy.log('add additional content') - cy.findByRole('region', { name: 'Editor' }).within(() => { - cy.findByText('\\maketitle').parent().click() - cy.findByText('\\maketitle').parent().type(`\n\\section{{}New Section}`) - }) - recompile() - - cy.log( - 'check flush from frontend to backend: should include new section in PDF' - ) - cy.findByRole('region', { name: 'PDF preview' }).should( - 'contain.text', - 'New Section' - ) - - cy.log('should have unflushed content in redis before shutdown') - cy.then(async () => { - const keys = await getRedisKeys() - expect(keys).to.contain(`DocsIn:${projectId}`) - expect(keys).to.contain(`ProjectHistory:Ops:{${projectId}}`) - }) - - cy.log('trigger graceful shutdown') - let pendingShutdown: Promise - cy.then(() => { - pendingShutdown = dockerCompose('stop', '--timeout=60', 'sharelatex') - }) - - cy.log('wait for banner') - cy.findByRole('dialog').findByText(/performing maintenance/) - cy.log('wait for page reload') - cy.findByRole('heading', { name: 'Maintenance' }) - cy.findByText(/is currently down for maintenance/) - - cy.log('wait for shutdown to complete') - cy.then({ timeout: 60 * 1000 }, async () => { - await pendingShutdown - }) - - cy.log('should not have any unflushed content in redis after shutdown') - cy.then(async () => { - const keys = await getRedisKeys() - expect(keys).to.not.contain(`DocsIn:${projectId}`) - expect(keys).to.not.contain(`ProjectHistory:Ops:{${projectId}}`) - }) - - bringServerProBackUp() - - cy.then(() => { - cy.visit(`/project/${projectId}?trick-cypress-into-page-reload=true`) - }) - - cy.log('check loading doc from mongo') - cy.findByRole('region', { name: 'Editor' }).findByText('New Section') - - cy.log('check PDF') - cy.findByRole('region', { name: 'PDF preview' }).should( - 'contain.text', - 'New Section' - ) - cy.log('check history') - cy.findByRole('navigation', { - name: 'Project actions', - }) - .findByRole('button', { name: 'History' }) - .click() - cy.findByText(/\\section\{New Section}/) - }) -}) diff --git a/server-ce/test/helpers/beforeWithReRunOnTestRetry.ts b/server-ce/test/helpers/beforeWithReRunOnTestRetry.ts deleted file mode 100644 index 0b67e3c910..0000000000 --- a/server-ce/test/helpers/beforeWithReRunOnTestRetry.ts +++ /dev/null @@ -1,8 +0,0 @@ -export function beforeWithReRunOnTestRetry(fn: () => void | Promise) { - let ranOnce = false - beforeEach(function () { - if (ranOnce && Cypress.currentRetry === 0) return - ranOnce = true - return fn() - }) -} diff --git a/server-ce/test/helpers/compile.ts b/server-ce/test/helpers/compile.ts deleted file mode 100644 index 3ceb14e320..0000000000 --- a/server-ce/test/helpers/compile.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Helper function for throttling clicks on the recompile button to avoid hitting server side rate limits. - * The naive approach is waiting a fixed a mount of time (3s) just before clicking the button. - * This helper takes into account that other UI interactions take time. We can deduce that latency from the fixed delay (3s minus other latency). This can bring down the effective waiting time to 0s. - */ - -export function stopCompile(options: { delay?: number } = {}) { - const { delay = 0 } = options - cy.wait(delay) - cy.log('Stop compile') - cy.findByRole('button', { name: 'Toggle compile options menu' }).click() - cy.findByRole('menuitem', { name: 'Stop compilation' }) - .should('not.have.class', 'disabled') - .and('not.have.attr', 'aria-disabled', 'true') - .click() -} - -export function prepareWaitForNextCompileSlot() { - let lastCompile = 0 - function queueReset() { - cy.then(() => { - lastCompile = Date.now() - }) - } - function waitForCompileRateLimitCoolOff() { - cy.then(() => { - cy.log('Wait for recompile rate-limit to cool off') - const msSinceLastCompile = Date.now() - lastCompile - cy.wait(Math.max(0, 1_000 - msSinceLastCompile)) - queueReset() - }) - } - function waitForCompile(triggerCompile: () => void) { - waitForCompileRateLimitCoolOff() - cy.then(() => { - let compilingVisible: () => void - const waitForCompilingVisible = new Promise(resolve => { - compilingVisible = resolve - }) - cy.intercept( - { - method: 'POST', - pathname: /\/project\/[a-fA-F0-9]{24}\/compile$/, - times: 1, - }, - async req => { - await waitForCompilingVisible - req.continue() - } - ).as('recompile') - triggerCompile() - cy.log('Wait for compile to finish') - cy.findByRole('button', { name: 'Compiling…' }).then(() => - compilingVisible() - ) - cy.wait('@recompile') - cy.findByRole('button', { name: 'Compiling…' }).should('not.exist') - cy.findByRole('button', { name: 'Recompile' }).should('be.visible') - }) - } - function recompile() { - waitForCompile(() => { - cy.findByRole('button', { name: 'Recompile' }).click() - }) - } - return { - queueReset, - waitForCompileRateLimitCoolOff, - waitForCompile, - recompile, - } -} diff --git a/server-ce/test/helpers/config.ts b/server-ce/test/helpers/config.ts deleted file mode 100644 index 494345dd6b..0000000000 --- a/server-ce/test/helpers/config.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { reconfigure } from './hostAdminClient' -import { resetActivateUserRateLimit, resetCreatedUsersCache } from './login' - -export const STARTUP_TIMEOUT = - parseInt(Cypress.env('STARTUP_TIMEOUT'), 10) || 120_000 - -export function isExcludedBySharding( - shard: - | 'LOCAL_ONLY' - | 'CE_DEFAULT' - | 'CE_CUSTOM_1' - | 'PRO_DEFAULT_1' - | 'PRO_DEFAULT_2' - | 'PRO_CUSTOM_1' - | 'PRO_CUSTOM_2' - | 'PRO_CUSTOM_3' - | 'PRO_CUSTOM_4' - | 'PRO_CUSTOM_5' -) { - const SHARD = Cypress.env('SHARD') - return SHARD && shard !== SHARD -} - -let previousConfigFrontend: string - -export function startWith({ - pro = false, - version = 'latest', - vars = {}, - varsFn = () => ({}), - withDataDir = false, - resetData = false, - mongoVersion = '', -}) { - before(async function () { - Object.assign(vars, varsFn()) - const cfg = JSON.stringify({ - pro, - version, - vars, - withDataDir, - resetData, - mongoVersion, - }) - if (resetData) { - cy.log('resetting data and sessions') - resetCreatedUsersCache() - resetActivateUserRateLimit() - // no return here, always reconfigure when resetting data - } else if (previousConfigFrontend === cfg) { - cy.log(`already running with ${cfg}`) - return - } - cy.log(`starting with ${cfg}`) - - this.timeout(STARTUP_TIMEOUT) - previousConfigFrontend = '' - const { previousConfigServer } = await reconfigure({ - pro, - version, - vars, - withDataDir, - resetData, - mongoVersion, - }) - if (previousConfigServer !== cfg) { - await Cypress.session.clearAllSavedSessions() - } - previousConfigFrontend = cfg - }) -} - -// Allow reloading the server in other places, e.g. beforeEach hooks. -export async function reloadWith({ - pro = false, - version = 'latest', - vars = {}, - varsFn = () => ({}), - withDataDir = false, - resetData = false, - mongoVersion = '', -}) { - Object.assign(vars, varsFn()) - const cfg = JSON.stringify({ - pro, - version, - vars, - withDataDir, - resetData, - mongoVersion, - }) - if (resetData) { - resetCreatedUsersCache() - resetActivateUserRateLimit() - // no return here, always reconfigure when resetting data - } else if (previousConfigFrontend === cfg) { - return - } - previousConfigFrontend = '' - const { previousConfigServer } = await reconfigure({ - pro, - version, - vars, - withDataDir, - resetData, - mongoVersion, - }) - if (previousConfigServer !== cfg) { - await Cypress.session.clearAllSavedSessions() - } - previousConfigFrontend = cfg -} - -export { reconfigure } diff --git a/server-ce/test/helpers/email.ts b/server-ce/test/helpers/email.ts deleted file mode 100644 index ab5150a33f..0000000000 --- a/server-ce/test/helpers/email.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Helper function for opening an email in Roundcube based mailtrap. - * We need to cross an origin boundary, which complicates the use of variables. - * Any variables need to be explicitly defined and the "runner" may only reference these and none from its scope. - * It is not possible to use Cypress helper functions, e.g. from the testing library or other functions like "activateUser", inside the "runner". - * REF: https://github.com/testing-library/cypress-testing-library/issues/221 - */ -export function openEmail( - subject: string | RegExp, - runner: ( - frame: Cypress.Chainable>, - args: T - ) => void, - args?: T -) { - const runnerS = runner.toString() - cy.origin( - Cypress.env('MAILTRAP_URL') || 'http://mailtrap', - { args: { args, runnerS, subject } }, - ({ args, runnerS, subject }) => { - cy.visit('/') - cy.get('input[name="_user"]').type('mailtrap') - cy.get('input[name="_pass"]').type('password-for-mailtrap') - cy.get('button[type="submit"]').click() - cy.url().then(url => { - if (!url.includes('?_task=login')) return - cy.log('mailtrap login is flaky in cypress, submit again') - cy.get('input[name="_pass"]').type('password-for-mailtrap') - cy.get('button[type="submit"]').click() - }) - // Use force as the subject is partially hidden - cy.contains(subject).click({ force: true }) - cy.log('wait for iframe loading') - // eslint-disable-next-line cypress/no-unnecessary-waiting - cy.wait(1000) - cy.get('iframe[id="messagecontframe"]').then(frame => { - // runnerS='(frame, args) => { runner body }'. Extract the runnable function. - // eslint-disable-next-line no-new-func - const runner = new Function('return ' + runnerS)() - runner(cy.wrap(frame.prop('contentWindow').document.body), args) - }) - } - ) -} diff --git a/server-ce/test/helpers/hostAdminClient.ts b/server-ce/test/helpers/hostAdminClient.ts deleted file mode 100644 index 5934fa1fca..0000000000 --- a/server-ce/test/helpers/hostAdminClient.ts +++ /dev/null @@ -1,136 +0,0 @@ -const hostAdminURL = Cypress.env('HOST_ADMIN_URL') || 'http://host-admin' - -export async function dockerCompose(cmd: string, ...args: string[]) { - return await fetchJSON(`${hostAdminURL}/docker/compose/${cmd}`, { - method: 'POST', - body: JSON.stringify({ - args, - }), - }) -} - -export async function reconfigure({ - pro = false, - version = 'latest', - vars = {}, - withDataDir = false, - resetData = false, - mongoVersion = '', -}): Promise<{ previousConfigServer: string }> { - return await fetchJSON(`${hostAdminURL}/reconfigure`, { - method: 'POST', - body: JSON.stringify({ - pro, - version, - vars, - withDataDir, - resetData, - mongoVersion, - }), - }) -} - -async function fetchJSON( - input: RequestInfo, - init?: RequestInit -): Promise { - if (init?.body) { - init.headers = { 'Content-Type': 'application/json' } - } - let res - for (let attempt = 0; attempt < 5; attempt++) { - try { - res = await fetch(input, init) - break - } catch { - await sleep(3_000) - } - } - if (!res) { - res = await fetch(input, init) - } - const { error, stdout, stderr, ...rest } = await res.json() - if (error) { - console.error(input, init, 'failed:', error) - if (stdout) console.log(stdout) - if (stderr) console.warn(stderr) - const err = new Error(error.message) - Object.assign(err, error) - throw err - } - return { stdout, stderr, ...rest } -} - -export async function runScript({ - cwd, - script, - args = [], - user = 'www-data', - hasOverleafEnv = true, -}: { - cwd: string - script: string - args?: string[] - user?: string - hasOverleafEnv?: boolean -}) { - return await fetchJSON(`${hostAdminURL}/run/script`, { - method: 'POST', - body: JSON.stringify({ - cwd, - script, - args, - user, - hasOverleafEnv, - }), - }) -} - -export async function runGruntTask({ - task, - args = [], -}: { - task: string - args?: string[] -}) { - return await fetchJSON(`${hostAdminURL}/run/gruntTask`, { - method: 'POST', - body: JSON.stringify({ - task, - args, - }), - }) -} - -export async function getRedisKeys() { - const { stdout } = await fetchJSON(`${hostAdminURL}/redis/keys`, { - method: 'GET', - }) - return stdout.split('\n') -} - -export async function setMongoFeatureCompatibilityVersion( - mongoVersion: string -) { - cy.log(`advancing mongo featureCompatibilityVersion to ${mongoVersion}`) - await fetchJSON(`${hostAdminURL}/mongo/setFeatureCompatibilityVersion`, { - method: 'POST', - body: JSON.stringify({ - mongoVersion, - }), - }) -} - -export async function purgeFilestoreData() { - const { stdout } = await fetchJSON(`${hostAdminURL}/data/user_files`, { - method: 'DELETE', - }) - if (!stdout.trim()) return [] - return stdout.trim().split('\n') -} - -async function sleep(ms: number) { - return await new Promise(resolve => { - setTimeout(resolve, ms) - }) -} diff --git a/server-ce/test/helpers/login.ts b/server-ce/test/helpers/login.ts deleted file mode 100644 index e34d45ff1b..0000000000 --- a/server-ce/test/helpers/login.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { runScript } from './hostAdminClient' - -export const DEFAULT_PASSWORD = 'Passw0rd!' - -const createdUsers = new Set() - -export function resetCreatedUsersCache() { - createdUsers.clear() -} - -export async function createMongoUser({ - email, - isAdmin = false, -}: { - email: string - isAdmin?: boolean -}) { - const t0 = Math.floor(Date.now() / 1000) - const { stdout } = await runScript({ - cwd: 'services/web', - script: 'modules/server-ce-scripts/scripts/create-user.js', - args: [`--email=${email}`, `--admin=${isAdmin}`], - }) - const [url] = stdout.match(/http:\/\/.+\/user\/activate\?token=\S+/)! - const userId = new URL(url, location.origin).searchParams.get('user_id')! - const signupDate = parseInt(userId.slice(0, 8), 16) - if (signupDate < t0) { - return { url, exists: true } - } - return { url, exists: false } -} - -export function ensureUserExists({ - email, - password = DEFAULT_PASSWORD, - isAdmin = false, -}: { - email: string - password?: string - isAdmin?: boolean -}) { - let url: string - let exists: boolean - before(async function () { - exists = createdUsers.has(email) - if (exists) return - ;({ url, exists } = await createMongoUser({ email, isAdmin })) - }) - before(function () { - if (exists) return - activateUser(url, password) - cy.then(() => { - createdUsers.add(email) - }) - }) -} - -export function login(username: string, password = DEFAULT_PASSWORD) { - cy.session( - ['via-login', username, password], - () => { - cy.visit('/login') - cy.get('input[name="email"]').type(username) - cy.get('input[name="password"]').type(password) - cy.findByRole('button', { name: 'Login' }).click() - cy.url().should('contain', '/project') - }, - { - cacheAcrossSpecs: true, - async validate() { - // Hit a cheap endpoint that is behind AuthenticationController.requireLogin(). - cy.request({ url: '/user/personal_info', followRedirect: false }).then( - response => { - expect(response.status).to.equal(200) - } - ) - }, - } - ) -} - -let activateRateLimitState = { count: 0, reset: 0 } -export function resetActivateUserRateLimit() { - activateRateLimitState = { count: 0, reset: 0 } -} - -function handleActivateUserRateLimit() { - cy.then(() => { - activateRateLimitState.count++ - if (activateRateLimitState.reset < Date.now()) { - activateRateLimitState.reset = Date.now() + 65_000 - activateRateLimitState.count = 1 - } else if (activateRateLimitState.count >= 6) { - cy.wait(activateRateLimitState.reset - Date.now()) - activateRateLimitState.count = 1 - } - }) -} - -export function activateUser(url: string, password = DEFAULT_PASSWORD) { - handleActivateUserRateLimit() - - cy.session(url, () => { - cy.visit(url) - cy.url().then(url => { - if (url.includes('/login')) return - cy.url().should('contain', '/user/activate') - cy.get('input[name="password"]').type(password) - cy.findByRole('button', { name: 'Activate' }).click() - cy.url().should('contain', '/project') - }) - }) -} diff --git a/server-ce/test/helpers/project.ts b/server-ce/test/helpers/project.ts deleted file mode 100644 index 9c70947474..0000000000 --- a/server-ce/test/helpers/project.ts +++ /dev/null @@ -1,402 +0,0 @@ -import { login } from './login' -import { openEmail } from './email' -import { v4 as uuid } from 'uuid' -import { prepareWaitForNextCompileSlot } from './compile' - -export const NEW_PROJECT_BUTTON_MATCHER = /new project/i - -const NEW_EDITOR_QUERY_PARAMS = '' -const OLD_EDITOR_QUERY_PARAMS = '?old-editor-override=true' - -export function redirectEditorUrlWithQueryParams(newEditor: boolean) { - const queryString = newEditor - ? NEW_EDITOR_QUERY_PARAMS - : OLD_EDITOR_QUERY_PARAMS - cy.intercept( - { method: 'GET', url: /\/project\/[a-fA-F0-9]{24}$/, times: 1 }, - req => { - // Intercept redirect and add the query param to use the new editor - req.redirect(`${req.url}${queryString}`) - } - ) -} - -export function createProject( - name: string, - { - type = 'Blank project', - newProjectButtonMatcher = NEW_PROJECT_BUTTON_MATCHER, - open = true, - newEditor = false, - }: { - type?: 'Blank project' | 'Example project' - newProjectButtonMatcher?: RegExp - open?: boolean - newEditor?: boolean - } = {} -): Cypress.Chainable { - cy.url().then(url => { - if (!url.endsWith('/project')) { - cy.visit('/project') - } - }) - const interceptId = uuid() - let projectId = '' - if (!open) { - cy.then(() => { - // Register intercept just before creating the project, otherwise we might - // intercept a request from a prior createProject invocation. - cy.intercept( - { method: 'GET', url: /\/project\/[a-fA-F0-9]{24}$/, times: 1 }, - req => { - projectId = req.url.split('/').pop()!.split('?')[0] - // Redirect back to the project dashboard, effectively reload the page. - req.redirect('/project') - } - ).as(interceptId) - }) - } else { - redirectEditorUrlWithQueryParams(newEditor) - } - cy.findAllByRole('button').contains(newProjectButtonMatcher).click() - // FIXME: This should only look in the left menu - // The upgrading tests create projects in older versions of Server Pro which used different casing of the project type. Use case-insensitive match. - cy.findAllByText(type, { exact: false }).first().click() - cy.findByRole('dialog').within(() => { - cy.get('input').type(name) - cy.findByRole('button', { name: 'Create' }).click() - }) - if (open) { - cy.url().should('match', /\/project\/[a-fA-F0-9]{24}/) - waitForMainDocToLoad() - return cy - .url() - .should('match', /\/project\/[a-fA-F0-9]{24}/) - .then(url => url.split('/').pop()?.split('?')[0]) - } else { - const alias = `@${interceptId}` // IDEs do not like computed values in cy.wait(). - cy.wait(alias) - return cy.then(() => projectId) - } -} - -// TODO ide-redesign-cleanup: Remove this and just use createProject directly -export function createProjectAndOpenInNewEditor( - projectName: string, - options: { - type?: 'Blank project' | 'Example project' - newProjectButtonMatcher?: RegExp - } = {} -) { - return createProject(projectName, { ...options, open: false }).then( - projectId => { - // Open the new project in the new editor - openProjectById(projectId, true) - return cy.then(() => projectId) - } - ) -} - -export function openProjectByName(projectName: string, newEditor = false) { - cy.visit('/project') - - redirectEditorUrlWithQueryParams(newEditor) - - cy.findByText(projectName).click() - waitForMainDocToLoad() - - if (newEditor) { - // Close the beta intro modal if it appears - // TODO ide-redesign-cleanup: Remove this when the intro modal is removed - cy.get('body').type('{esc}') - } -} - -export function openProjectById(projectId: string, newEditor = false) { - const url = newEditor - ? `/project/${projectId}${NEW_EDITOR_QUERY_PARAMS}` - : `/project/${projectId}${OLD_EDITOR_QUERY_PARAMS}` - cy.visit(url) - waitForMainDocToLoad() - - if (newEditor) { - // Close the beta intro modal if it appears - // TODO ide-redesign-cleanup: Remove this when the intro modal is removed - cy.get('body').type('{esc}') - } -} - -export function openProjectViaLinkSharingAsAnon( - url: string, - newEditor = false -) { - redirectEditorUrlWithQueryParams(newEditor) - cy.visit(url) - waitForMainDocToLoad() - - if (newEditor) { - // Close the beta intro modal if it appears - // TODO ide-redesign-cleanup: Remove this when the intro modal is removed - cy.get('body').type('{esc}') - } -} - -export function openProjectViaLinkSharingAsUser( - url: string, - projectName: string, - email: string, - newEditor: boolean = false -) { - cy.visit(url) - cy.findByText(projectName) // wait for lazy loading - cy.contains(`as ${email}`) - - redirectEditorUrlWithQueryParams(newEditor) - - cy.findByText('OK, join project').click() - waitForMainDocToLoad() - - if (newEditor) { - // Close the beta intro modal if it appears - // TODO ide-redesign-cleanup: Remove this when the intro modal is removed - cy.get('body').type('{esc}') - } -} - -export function openProjectViaInviteNotification( - projectName: string, - newEditor: boolean = false -) { - cy.visit('/project') - cy.findByText(projectName) - .parent() - .parent() - .within(() => { - cy.findByText('Join Project').click() - }) - - cy.intercept( - // NOTE: Struggling to work out why but this only seems to work with times: 2. - // This is temporary code so leaving it for now. - { method: 'GET', url: /\/project\/[a-fA-F0-9]{24}$/, times: 2 }, - req => { - const queryString = newEditor - ? NEW_EDITOR_QUERY_PARAMS - : OLD_EDITOR_QUERY_PARAMS - // Intercept redirect and add the query param to use the new editor - req.redirect(`${req.url}${queryString}`) - } - ) - - const { waitForCompile } = prepareWaitForNextCompileSlot() - waitForCompile(() => { - cy.findByText('Open Project').click() - - cy.url().should('match', /\/project\/[a-fA-F0-9]{24}/) - waitForMainDocToLoad() - - if (newEditor) { - // Close the beta intro modal if it appears - // TODO ide-redesign-cleanup: Remove this when the intro modal is removed - cy.get('body').type('{esc}') - } - }) -} - -function shareProjectByEmail( - projectName: string, - email: string, - level: 'Viewer' | 'Editor' | 'Reviewer', - newEditor: boolean = false -) { - openProjectByName(projectName, newEditor) - cy.findByRole('button', { name: 'Share' }).click() - cy.findByRole('dialog').within(() => { - cy.findByLabelText('Add email address', { selector: 'input' }).type( - `${email},` - ) - cy.findByLabelText('Add email address', { selector: 'input' }) - .parents('form') - .within(() => { - cy.findByTestId('add-collaborator-select').as('select').click() - cy.get('@select').then(() => { - cy.findByRole('option', { name: level }).click() - }) - }) - cy.findByRole('button', { name: 'Invite' }).click() - cy.findByText('Invite not yet accepted.') - }) -} - -export function shareProjectByEmailAndAcceptInviteViaDash( - projectName: string, - email: string, - level: 'Viewer' | 'Editor' | 'Reviewer', - newEditor: boolean = false -) { - shareProjectByEmail(projectName, email, level, newEditor) - - login(email) - openProjectViaInviteNotification(projectName, newEditor) -} - -export function getSpamSafeProjectName() { - while (true) { - // Move from hex/16 to base64/64 possible characters per char in string - const name = Buffer.from(uuid().replaceAll('-', ''), 'hex') - .toString('base64') - .replaceAll('/', 'x') - .replaceAll('+', 'y') - .slice(0, 10) - const nDigits = (name.match(/\d/g) || []).length - if (nDigits < 6) return name - } -} - -export function shareProjectByEmailAndAcceptInviteViaEmail( - projectName: string, - email: string, - level: 'Viewer' | 'Editor', - newEditor: boolean = false -) { - shareProjectByEmail(projectName, email, level, newEditor) - - login(email) - - openEmail(projectName, frame => { - frame.contains('View project').then(a => { - cy.log( - 'bypass target=_blank and navigate current browser tab/cypress-iframe to project invite' - ) - cy.visit(a.attr('href')!) - }) - }) - cy.url().should('match', /\/project\/[a-f0-9]+\/invite\/token\/[a-f0-9]+/) - cy.findByText(/user would like you to join/) - cy.contains(new RegExp(`You are accepting this invite as ${email}`)) - - redirectEditorUrlWithQueryParams(newEditor) - - cy.findByText('Join Project').click() - waitForMainDocToLoad() - - if (newEditor) { - // Close the beta intro modal if it appears - // TODO ide-redesign-cleanup: Remove this when the intro modal is removed - cy.get('body').type('{esc}') - } -} - -export function enableLinkSharing() { - let linkSharingReadOnly: string - let linkSharingReadAndWrite: string - const origin = new URL(Cypress.config().baseUrl!).origin - - waitForMainDocToLoad() - - cy.findByText('Share').click() - cy.findByText('Turn on link sharing').click() - cy.findByText('Anyone with this link can view this project') - .next() - .should('contain.text', origin + '/read') - .then(el => { - linkSharingReadOnly = el.text() - }) - cy.findByText('Anyone with this link can edit this project') - .next() - .should('contain.text', origin + '/') - .then(el => { - linkSharingReadAndWrite = el.text() - }) - - return cy.then(() => { - return { linkSharingReadOnly, linkSharingReadAndWrite } - }) -} - -export function waitForMainDocToLoad() { - cy.log('Wait for main doc to load; it will steal the focus after loading') - cy.get('.cm-content').should('contain.text', 'Introduction') -} - -export function openFile(fileName: string, waitFor: string) { - // force: The file-tree pane is too narrow to display the full name. - cy.findByRole('navigation', { name: 'Project files and outline' }) - .findByRole('treeitem', { name: fileName }) - .click({ force: true }) - - // wait until we've switched to the selected file - cy.findByRole('status').should('not.exist') - cy.findByText(waitFor) -} - -export function createNewFile() { - const fileName = `${uuid()}.tex` - - cy.log('create new project file') - cy.get('button').contains('New file').click({ force: true }) - cy.findByRole('dialog').within(() => { - cy.get('input').clear() - cy.get('input').type(fileName) - cy.findByText('Create').click() - }) - // force: The file-tree pane is too narrow to display the full name. - cy.findByTestId('file-tree') - .findByRole('treeitem', { name: fileName }) - .click({ force: true }) - - // wait until we've switched to the newly created empty file - cy.findByRole('textbox', { name: 'Source Editor editing' }).within(() => { - cy.findByRole('status').should('not.exist') - cy.get('.cm-line').should('have.length', 1) - }) - cy.get('.cm-line').should('have.length', 1) - - return fileName -} - -export function expectFileExists( - name: string, - binary: boolean, - content: string -) { - cy.findByRole('treeitem', { name }).click() - if (binary) { - cy.findByText(content).should('not.have.class', 'cm-line') - } else { - cy.findByText(content).should('have.class', 'cm-line') - } -} - -export function prepareFileUploadTest(binary = false) { - const name = `${uuid()}.txt` - const content = `Test File Content ${name}${binary ? ' \x00' : ''}` - cy.get('button').contains('Upload').click({ force: true }) - cy.get('input[type=file]') - .first() - .selectFile( - { - contents: Cypress.Buffer.from(content), - fileName: name, - lastModified: Date.now(), - }, - { force: true } - ) - - // wait for the upload to finish - cy.findByRole('treeitem', { name }) - - return () => expectFileExists(name, binary, content) -} - -export function testNewFileUpload() { - it('can upload text file', function () { - const check = prepareFileUploadTest(false) - check() - }) - - it('can upload binary file', function () { - const check = prepareFileUploadTest(true) - check() - }) -} diff --git a/server-ce/test/helpers/read-file.ts b/server-ce/test/helpers/read-file.ts deleted file mode 100644 index bdd2ad11a5..0000000000 --- a/server-ce/test/helpers/read-file.ts +++ /dev/null @@ -1,61 +0,0 @@ -import fs from 'node:fs' -import path from 'node:path' -import { getDocument } from 'pdfjs-dist/legacy/build/pdf.mjs' -import AdmZip from 'adm-zip' -import { setTimeout } from 'node:timers/promises' - -const MAX_ATTEMPTS = 15 -const POLL_INTERVAL = 500 - -type ReadFileInZipArgs = { - pathToZip: string - fileToRead: string -} - -export async function readFileInZip({ - pathToZip, - fileToRead, -}: ReadFileInZipArgs) { - let attempt = 0 - while (attempt < MAX_ATTEMPTS) { - if (fs.existsSync(pathToZip)) { - const zip = new AdmZip(path.resolve(pathToZip)) - const entry = zip - .getEntries() - .find(entry => entry.entryName === fileToRead) - if (entry) { - return entry.getData().toString('utf8') - } else { - throw new Error(`${fileToRead} not found in ${pathToZip}`) - } - } - await setTimeout(POLL_INTERVAL) - attempt++ - } - throw new Error(`${pathToZip} not found`) -} - -export async function readPdf(file: string) { - let attempt = 0 - while (attempt < MAX_ATTEMPTS) { - if (fs.existsSync(file)) { - const pdf = await getDocument(file).promise - const text = [] - try { - for (let i = 1; i <= pdf.numPages; i++) { - const page = await pdf.getPage(i) - const content = await page.getTextContent() - for (const item of content.items) { - if ('str' in item) text.push(item.str) - } - } - return text.join('\n') - } finally { - await pdf.destroy() - } - } - await setTimeout(POLL_INTERVAL) - attempt++ - } - throw new Error(`${file} not found`) -} diff --git a/server-ce/test/helpers/waitUntilScrollingFinished.ts b/server-ce/test/helpers/waitUntilScrollingFinished.ts deleted file mode 100644 index af4c5657d1..0000000000 --- a/server-ce/test/helpers/waitUntilScrollingFinished.ts +++ /dev/null @@ -1,39 +0,0 @@ -export function waitUntilScrollingFinished(selector: string, start = -1) { - const pollSlow = 100 - const pollFast = 10 - const deadline = - performance.now() + Cypress.config('defaultCommandTimeout') - pollSlow * 2 - return cy.get(selector).then(el => { - cy.log( - `waiting until scrolling finished for ${selector}, starting from ${start}` - ) - return new Cypress.Promise((resolve, reject) => { - const waitForStable = (prev: number, stableFor: number) => { - if (performance.now() > deadline) { - return reject(new Error('timeout waiting for scrolling to finish')) - } - const current = el.scrollTop()! - if (current !== prev) { - setTimeout(() => waitForStable(current, 0), pollFast) - } else if (stableFor < 5) { - setTimeout(() => waitForStable(current, stableFor + 1), pollFast) - } else { - resolve(current) - } - } - - const waitForChange = () => { - if (performance.now() > deadline) { - return reject(new Error('timeout waiting for scrolling to start')) - } - const current = el.scrollTop()! - if (current === start) { - setTimeout(() => waitForChange(), pollSlow) - } else { - setTimeout(() => waitForStable(current, 0), pollFast) - } - } - waitForChange() - }) - }) -} diff --git a/server-ce/test/history.spec.ts b/server-ce/test/history.spec.ts deleted file mode 100644 index 3afb975557..0000000000 --- a/server-ce/test/history.spec.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { createProjectAndOpenInNewEditor } from './helpers/project' -import { prepareWaitForNextCompileSlot } from './helpers/compile' -import { ensureUserExists, login } from './helpers/login' -import { isExcludedBySharding, startWith } from './helpers/config' - -describe('History', function () { - if (isExcludedBySharding('CE_DEFAULT')) return - startWith({}) - ensureUserExists({ email: 'user@example.com' }) - beforeEach(function () { - login('user@example.com') - }) - - function addLabel(name: string) { - cy.log(`add label ${JSON.stringify(name)}`) - // The input is not clickable due to being visually hidden, click its label instead - cy.findByRole('complementary', { - name: 'Project history and labels', - }).within(() => { - cy.findByRole('group', { - name: 'Show all of the project history or only labelled versions.', - }).within(() => { - cy.findByText('Labels').click() - }) - cy.findByRole('radio', { name: 'Labels' }).should('be.checked') - cy.findByRole('radio', { name: 'All history' }).should('not.be.checked') - cy.findAllByTestId('history-version-details') - .first() - .within(() => { - cy.findByRole('button', { name: 'More actions' }).click() - cy.findByRole('menuitem', { name: 'Label this version' }).click() - }) - }) - cy.findByRole('dialog').within(() => { - cy.findByLabelText('New label name').type(`${name}{enter}`) - }) - } - - function downloadVersion(name: string) { - cy.log(`download version ${JSON.stringify(name)}`) - // The input is not clickable due to being visually hidden, click its label instead - cy.findByRole('complementary', { - name: 'Project history and labels', - }).within(() => { - cy.findByRole('group', { - name: 'Show all of the project history or only labelled versions.', - }).within(() => { - cy.findByText('Labels').click() - }) - cy.findByRole('radio', { name: 'Labels' }).should('be.checked') - cy.findByRole('radio', { name: 'All history' }).should('not.be.checked') - cy.findByText(name) - .closest('[data-testid="history-version-details"]') - .within(() => { - cy.findByRole('button', { name: 'More actions' }).click() - cy.findByRole('menuitem', { name: 'Download this version' }).click() - }) - }) - } - - const CLASS_ADDITION = 'ol-cm-addition-marker' - const CLASS_DELETION = 'ol-cm-deletion-marker' - - it('should support labels, comparison and download', function () { - const { recompile, waitForCompile } = prepareWaitForNextCompileSlot() - waitForCompile(() => { - createProjectAndOpenInNewEditor('labels') - }) - - cy.log('add content, including a line that will get removed soon') - cy.findByRole('textbox', { name: 'Source Editor editing' }).within(() => { - cy.findByText('\\maketitle').parent().click() - cy.findByText('\\maketitle').parent().type('\n% added') - cy.findByText('\\maketitle').parent().type('\n% to be removed') - }) - recompile() - cy.findByRole('button', { name: 'History' }).click() - - cy.log('expect to see additions in history') - cy.get('.document-diff-container').within(() => { - cy.findByText('% to be removed').should('have.class', CLASS_ADDITION) - cy.findByText('% added').should('have.class', CLASS_ADDITION) - }) - - addLabel('Before removal') - - cy.log('remove content') - cy.findByRole('button', { name: 'Back to editor' }).click() - cy.findByText('% to be removed').parent().type('{end}{shift}{upArrow}{del}') - recompile() - cy.findByRole('button', { name: 'History' }).click() - - cy.log('expect to see annotation for newly removed content in history') - cy.get('.document-diff-container').within(() => { - cy.findByText('% to be removed').should('have.class', CLASS_DELETION) - cy.findByText('% added').should('not.have.class', CLASS_ADDITION) - }) - - addLabel('After removal') - - cy.log('add more content after labeling') - cy.findByRole('button', { name: 'Back to editor' }).click() - cy.findByRole('textbox', { name: 'Source Editor editing' }).within(() => { - cy.findByText('\\maketitle').parent().click() - cy.findByText('\\maketitle').parent().type('\n% more') - }) - recompile() - - cy.log('compare non current versions') - cy.findByRole('button', { name: 'History' }).click() - // The input is not clickable due to being visually hidden, click its label instead - cy.findByRole('complementary', { - name: 'Project history and labels', - }).within(() => { - cy.findByRole('group', { - name: 'Show all of the project history or only labelled versions.', - }).within(() => { - cy.findByText('Labels').click() - }) - cy.findByRole('radio', { name: 'Labels' }).should('be.checked') - cy.findByRole('radio', { name: 'All history' }).should('not.be.checked') - cy.findAllByTestId('compare-icon-version').last().click() - cy.findAllByTestId('compare-icon-version').filter(':visible').click() - cy.findByRole('menuitem', { - name: 'Compare up to this version', - }).click() - }) - cy.log( - 'expect to see annotation for removed content between the two versions' - ) - cy.get('.document-diff-container').within(() => { - cy.findByText('% to be removed').should('have.class', CLASS_DELETION) - cy.findByText('% added').should('not.have.class', CLASS_ADDITION) - cy.findByText('% more').should('not.exist') - }) - - downloadVersion('Before removal') - cy.task('readFileInZip', { - pathToZip: `cypress/downloads/labels (Version 2).zip`, - fileToRead: 'main.tex', - }) - .should('contain', '% added') - .should('contain', '% to be removed') - .should('not.contain', '% more') - - downloadVersion('After removal') - cy.task('readFileInZip', { - pathToZip: `cypress/downloads/labels (Version 3).zip`, - fileToRead: 'main.tex', - }) - .should('contain', '% added') - .should('not.contain', '% to be removed') - .should('not.contain', '% more') - - downloadVersion('Current state') - cy.task('readFileInZip', { - pathToZip: `cypress/downloads/labels (Version 4).zip`, - fileToRead: 'main.tex', - }) - .should('contain', '% added') - .should('not.contain', '% to be removed') - .should('contain', '% more') - }) -}) diff --git a/server-ce/test/host-admin.js b/server-ce/test/host-admin.js deleted file mode 100644 index 6cceae4b2c..0000000000 --- a/server-ce/test/host-admin.js +++ /dev/null @@ -1,494 +0,0 @@ -import fs from 'node:fs' -import Path from 'node:path' -import { promisify } from 'node:util' -import { execFile as execFileCb } from 'node:child_process' -import bodyParser from 'body-parser' -import express from 'express' -import YAML from 'js-yaml' -import { - InvalidParamsError, - InvalidRequestError, - parseReq, - z, -} from '@overleaf/validation-tools' -import { expressify } from '@overleaf/promise-utils' - -const execFile = promisify(execFileCb) - -const DATA_DIR = Path.join( - import.meta.dirname, - 'data', - // Give each shard their own data dir. - process.env.CYPRESS_SHARD || 'default' -) -const PATHS = { - DOCKER_COMPOSE_FILE: 'docker-compose.yml', - // Give each shard their own override file. - DOCKER_COMPOSE_OVERRIDE: `docker-compose.${process.env.CYPRESS_SHARD || 'override'}.yml`, - DOCKER_COMPOSE_NATIVE: 'docker-compose.native.yml', - DATA_DIR, - SANDBOXED_COMPILES_HOST_DIR: Path.join(DATA_DIR, 'compiles'), -} -const IMAGES = { - CE: process.env.IMAGE_TAG_CE.replace(/:.+/, ''), - PRO: process.env.IMAGE_TAG_PRO.replace(/:.+/, ''), - GIT_BRIDGE: process.env.IMAGE_TAG_GIT_BRIDGE.replace(/:.+/, ''), -} -const LATEST = { - CE: process.env.IMAGE_TAG_CE.replace(/.+:/, '') || 'latest', - PRO: process.env.IMAGE_TAG_PRO.replace(/.+:/, '') || 'latest', - GIT_BRIDGE: process.env.IMAGE_TAG_GIT_BRIDGE.replace(/.+:/, '') || 'latest', -} - -function defaultDockerComposeOverride() { - return { - services: { - sharelatex: { - environment: {}, - }, - 'git-bridge': {}, - mongo: {}, - }, - } -} - -let previousConfig = '' - -function readDockerComposeOverride() { - try { - return YAML.load(fs.readFileSync(PATHS.DOCKER_COMPOSE_OVERRIDE, 'utf-8')) - } catch (error) { - if (error.code !== 'ENOENT') { - throw error - } - return defaultDockerComposeOverride() - } -} - -function writeDockerComposeOverride(cfg) { - fs.writeFileSync(PATHS.DOCKER_COMPOSE_OVERRIDE, YAML.dump(cfg)) -} - -async function runDockerCompose(command, args) { - const files = ['-f', PATHS.DOCKER_COMPOSE_FILE] - if (process.env.NATIVE_CYPRESS) { - files.push('-f', PATHS.DOCKER_COMPOSE_NATIVE) - } - if (fs.existsSync(PATHS.DOCKER_COMPOSE_OVERRIDE)) { - files.push('-f', PATHS.DOCKER_COMPOSE_OVERRIDE) - } - return await execFile('docker', ['compose', ...files, command, ...args]) -} - -function purgeDataDir() { - fs.rmSync(PATHS.DATA_DIR, { recursive: true, force: true }) -} - -const app = express() -app.get('/status', (req, res) => { - res.send('host-admin is up') -}) - -app.use(bodyParser.json()) -app.use((req, res, next) => { - // Basic access logs - if (process.env.CI !== 'true') { - console.log(req.method, req.url, req.body) - } - const json = res.json - res.json = body => { - if (process.env.CI !== 'true' || body.error) { - console.log(req.method, req.url, req.body, '->', body) - } - json.call(res, body) - } - next() -}) -app.use((req, res, next) => { - // Add CORS headers - const accessControlAllowOrigin = - process.env.ACCESS_CONTROL_ALLOW_ORIGIN || 'http://sharelatex' - res.setHeader('Access-Control-Allow-Origin', accessControlAllowOrigin) - res.setHeader('Access-Control-Allow-Headers', 'Content-Type') - res.setHeader('Access-Control-Max-Age', '3600') - res.setHeader('Access-Control-Allow-Methods', 'DELETE, GET, HEAD, POST, PUT') - next() -}) - -app.post( - '/run/script', - expressify(async (req, res) => { - const { - body: { cwd, script, args, user, hasOverleafEnv }, - } = parseReq( - req, - z.object({ - body: z.object({ - cwd: z.string(), - script: z.string(), - args: z.array(z.string()), - user: z.string(), - hasOverleafEnv: z.boolean(), - }), - }) - ) - - const env = hasOverleafEnv - ? 'source /etc/overleaf/env.sh || source /etc/sharelatex/env.sh' - : 'true' - try { - const { stdout, stderr } = await runDockerCompose('exec', [ - '--workdir', - `/overleaf/${cwd}`, - 'sharelatex', - 'bash', - '-c', - `source /etc/container_environment.sh && ${env} && /sbin/setuser ${user} node ${script} ${args.map(a => JSON.stringify(a)).join(' ')}`, - ]) - res.json({ - stdout, - stderr, - }) - } catch (error) { - return res.json({ error }) - } - }) -) - -app.post( - '/run/gruntTask', - expressify(async (req, res) => { - const { - body: { task, args }, - } = parseReq( - req, - z.object({ - body: z.object({ - task: z.string(), - args: z.array(z.string()), - }), - }) - ) - - try { - const { stdout, stderr } = await runDockerCompose('exec', [ - '--workdir', - '/var/www/sharelatex', - 'sharelatex', - 'bash', - '-c', - `source /etc/container_environment.sh && /sbin/setuser www-data grunt ${JSON.stringify(task)} ${args.map(a => JSON.stringify(a)).join(' ')}`, - ]) - res.json({ stdout, stderr }) - } catch (error) { - return res.json({ error }) - } - }) -) - -const allowedVars = z.object( - Object.fromEntries( - [ - 'OVERLEAF_APP_NAME', - 'OVERLEAF_LEFT_FOOTER', - 'OVERLEAF_RIGHT_FOOTER', - 'OVERLEAF_PROXY_LEARN', - 'GIT_BRIDGE_ENABLED', - 'GIT_BRIDGE_HOST', - 'GIT_BRIDGE_PORT', - 'V1_HISTORY_URL', - 'SANDBOXED_COMPILES', - 'ALL_TEX_LIVE_DOCKER_IMAGE_NAMES', - 'OVERLEAF_FILESTORE_MIGRATION_LEVEL', - 'OVERLEAF_TEMPLATES_USER_ID', - 'OVERLEAF_NEW_PROJECT_TEMPLATE_LINKS', - 'OVERLEAF_ALLOW_PUBLIC_ACCESS', - 'OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING', - 'OVERLEAF_DISABLE_LINK_SHARING', - 'EXTERNAL_AUTH', - 'OVERLEAF_SAML_ENTRYPOINT', - 'OVERLEAF_SAML_CALLBACK_URL', - 'OVERLEAF_SAML_ISSUER', - 'OVERLEAF_SAML_IDENTITY_SERVICE_NAME', - 'OVERLEAF_SAML_EMAIL_FIELD', - 'OVERLEAF_SAML_FIRST_NAME_FIELD', - 'OVERLEAF_SAML_LAST_NAME_FIELD', - 'OVERLEAF_SAML_UPDATE_USER_DETAILS_ON_LOGIN', - 'OVERLEAF_SAML_CERT', - 'OVERLEAF_LDAP_URL', - 'OVERLEAF_LDAP_SEARCH_BASE', - 'OVERLEAF_LDAP_SEARCH_FILTER', - 'OVERLEAF_LDAP_BIND_DN', - 'OVERLEAF_LDAP_BIND_CREDENTIALS', - 'OVERLEAF_LDAP_EMAIL_ATT', - 'OVERLEAF_LDAP_NAME_ATT', - 'OVERLEAF_LDAP_LAST_NAME_ATT', - 'OVERLEAF_LDAP_UPDATE_USER_DETAILS_ON_LOGIN', - // Old branding, used for upgrade tests - 'SHARELATEX_SITE_URL', - 'SHARELATEX_MONGO_URL', - 'SHARELATEX_REDIS_HOST', - ].map(name => [name, z.string().optional()]) - ) -) - -function setVarsDockerCompose({ - pro, - vars, - version, - withDataDir, - mongoVersion, -}) { - const cfg = readDockerComposeOverride() - - cfg.services.sharelatex.image = `${pro ? IMAGES.PRO : IMAGES.CE}:${version === 'latest' ? (pro ? LATEST.PRO : LATEST.CE) : version}` - cfg.services['git-bridge'].image = - `${IMAGES.GIT_BRIDGE}:${version === 'latest' ? LATEST.GIT_BRIDGE : version}` - - cfg.services.sharelatex.environment = vars - - if (cfg.services.sharelatex.environment.GIT_BRIDGE_ENABLED === 'true') { - cfg.services.sharelatex.depends_on = ['git-bridge'] - } else { - cfg.services.sharelatex.depends_on = [] - } - - if (['ldap', 'saml'].includes(vars.EXTERNAL_AUTH)) { - cfg.services.sharelatex.depends_on.push(vars.EXTERNAL_AUTH) - } - - const dataDirInContainer = - version === 'latest' || version >= '5.0' - ? '/var/lib/overleaf' - : '/var/lib/sharelatex' - - cfg.services.sharelatex.volumes = [] - if (withDataDir) { - cfg.services.sharelatex.volumes.push( - `${PATHS.DATA_DIR}:${dataDirInContainer}` - ) - } - - if (cfg.services.sharelatex.environment.SANDBOXED_COMPILES === 'true') { - cfg.services.sharelatex.environment.SANDBOXED_COMPILES_HOST_DIR = - PATHS.SANDBOXED_COMPILES_HOST_DIR - cfg.services.sharelatex.environment.TEX_LIVE_DOCKER_IMAGE = - process.env.TEX_LIVE_DOCKER_IMAGE - cfg.services.sharelatex.environment.ALL_TEX_LIVE_DOCKER_IMAGES = - process.env.ALL_TEX_LIVE_DOCKER_IMAGES - cfg.services.sharelatex.volumes.push( - '/var/run/docker.sock:/var/run/docker.sock' - ) - if (!withDataDir) { - cfg.services.sharelatex.volumes.push( - `${PATHS.SANDBOXED_COMPILES_HOST_DIR}:${dataDirInContainer}/data/compiles` - ) - } - } - - if (mongoVersion) { - cfg.services.mongo.image = `mongo:${mongoVersion}` - } else { - delete cfg.services.mongo.image - } - - if (version === 'latest') { - cfg.services.mongo.command = '--replSet overleaf --notablescan' - } else { - delete cfg.services.mongo.command - } - - writeDockerComposeOverride(cfg) -} - -app.post( - '/docker/compose/:cmd', - expressify(async (req, res) => { - const { - params: { cmd }, - body: { args }, - } = parseReq( - req, - z.object({ - params: z.object({ - cmd: z.literal(['up', 'stop', 'down', 'ps', 'logs']), - }), - body: z.object({ - args: z.array( - z.literal([ - '--detach', - '--wait', - '--volumes', - '--timeout=60', - 'sharelatex', - 'git-bridge', - 'mongo', - 'redis', - ]) - ), - }), - }) - ) - - try { - const { stdout, stderr } = await runDockerCompose(cmd, args) - res.json({ stdout, stderr }) - } catch (error) { - return res.json({ error }) - } - }) -) - -async function maybeResetData(resetData) { - if (!resetData) return - - previousConfig = '' - await runDockerCompose('down', [ - '--timeout=0', - '--volumes', - 'mongo', - 'redis', - 'sharelatex', - ]) - purgeDataDir() -} - -app.post( - '/reconfigure', - expressify(async (req, res) => { - const { - body: { pro, version, vars, withDataDir, resetData, mongoVersion }, - } = parseReq( - req, - z.object({ - body: z.object({ - pro: z.boolean(), - version: z.string(), - vars: allowedVars, - withDataDir: z.boolean(), - resetData: z.boolean(), - mongoVersion: z.string(), - }), - }) - ) - try { - await maybeResetData(resetData) - } catch (error) { - return res.json({ error }) - } - - const previousConfigServer = previousConfig - const newConfig = JSON.stringify(req.body) - if (previousConfig === newConfig) { - return res.json({ previousConfigServer }) - } - - try { - setVarsDockerCompose({ pro, version, vars, withDataDir, mongoVersion }) - } catch (error) { - return res.json({ error }) - } - - try { - const { stdout, stderr } = await runDockerCompose('up', [ - '--detach', - '--wait', - 'sharelatex', - ]) - res.json({ stdout, stderr, previousConfigServer }) - } catch (error) { - return res.json({ - error, - previousConfigServer, - }) - } finally { - previousConfig = newConfig - } - }) -) - -app.post( - '/mongo/setFeatureCompatibilityVersion', - expressify(async (req, res) => { - const { - body: { mongoVersion }, - } = parseReq( - req, - z.object({ - body: z.object({ - mongoVersion: z.string(), - }), - }) - ) - const mongosh = mongoVersion > '5' ? 'mongosh' : 'mongo' - const params = { - setFeatureCompatibilityVersion: mongoVersion, - } - if (mongoVersion >= '7.0') { - // MongoServerError: Once you have upgraded to 7.0, you will not be able to downgrade FCV and binary version without support assistance. Please re-run this command with 'confirm: true' to acknowledge this and continue with the FCV upgrade. - // NOTE: 6.0 does not know about this flag. So conditionally add it. - // MongoServerError: BSON field 'setFeatureCompatibilityVersion.confirm' is an unknown field. - params.confirm = true - } - try { - const { stdout, stderr } = await runDockerCompose('exec', [ - 'mongo', - mongosh, - '--eval', - `db.adminCommand(${JSON.stringify(params)})`, - ]) - res.json({ stdout, stderr }) - } catch (error) { - return res.json({ error }) - } - }) -) - -app.get( - '/redis/keys', - expressify(async (req, res) => { - try { - const { stdout, stderr } = await runDockerCompose('exec', [ - 'redis', - 'redis-cli', - 'KEYS', - '*', - ]) - res.json({ stdout, stderr }) - } catch (error) { - return res.json({ error }) - } - }) -) - -app.delete( - '/data/user_files', - expressify(async (req, res) => { - try { - const { stdout, stderr } = await runDockerCompose('exec', [ - 'sharelatex', - 'rm', - '-vrf', - '/var/lib/overleaf/data/user_files', - ]) - res.json({ stdout, stderr }) - } catch (error) { - return res.json({ error }) - } - }) -) - -app.use((error, req, res, next) => { - if (error instanceof InvalidParamsError) { - res.status(404).json({ error }) - } else if (error instanceof InvalidRequestError) { - res.status(400).json({ error: error.zodError }) - } else { - next(error) - } -}) - -purgeDataDir() -writeDockerComposeOverride(defaultDockerComposeOverride()) - -app.listen(80) diff --git a/server-ce/test/learn-wiki.spec.ts b/server-ce/test/learn-wiki.spec.ts deleted file mode 100644 index ab6a55ae4a..0000000000 --- a/server-ce/test/learn-wiki.spec.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { isExcludedBySharding, startWith } from './helpers/config' -import { ensureUserExists, login } from './helpers/login' - -describe('LearnWiki', function () { - const COPYING_A_PROJECT_URL = '/learn/how-to/Copying_a_project' - const UPLOADING_A_PROJECT_URL = '/learn/how-to/Uploading_a_project' - - const WITHOUT_PROJECTS_USER = 'user-without-projects@example.com' - const REGULAR_USER = 'user@example.com' - - // Re-use value for "exists" and "does not exist" tests - const LABEL_LEARN_LATEX = 'Learn LaTeX with a tutorial' - - ensureUserExists({ email: WITHOUT_PROJECTS_USER }) - ensureUserExists({ email: REGULAR_USER }) - - describe('enabled in Pro', function () { - if (isExcludedBySharding('PRO_CUSTOM_2')) return - startWith({ - pro: true, - vars: { - OVERLEAF_PROXY_LEARN: 'true', - }, - }) - - it('should add a documentation entry to the nav bar', function () { - login(REGULAR_USER) - cy.visit('/project') - cy.findByRole('navigation', { name: 'Primary' }).findByRole('menuitem', { - name: 'Documentation', - }) - }) - - it('should display a tutorial link in the welcome page', function () { - login(WITHOUT_PROJECTS_USER) - cy.visit('/project') - cy.findByRole('link', { name: LABEL_LEARN_LATEX }).should( - 'have.attr', - 'href', - '/learn/latex/Learn_LaTeX_in_30_minutes' - ) - }) - - it('should render wiki page', function () { - login(REGULAR_USER) - - cy.visit(UPLOADING_A_PROJECT_URL) - cy.findByRole('heading', { name: 'Uploading a project' }) - cy.contains(/how to create an Overleaf project/) - cy.findByRole('img', { name: 'Creating a new project on Overleaf' }) - .should('be.visible') - .and((el: any) => { - expect(el[0].naturalWidth, 'renders image').to.be.greaterThan(0) - }) - - cy.visit(COPYING_A_PROJECT_URL) - cy.findByRole('heading', { name: 'Copying a project' }) - cy.findByRole('link', { - name: '1 How to copy a project (option 1)', - }).should('exist') - cy.findByRole('link', { - name: '2 How to copy a project (option 2)', - }).should('exist') - }) - - it('should navigate within wiki page using table of contents', function () { - login(REGULAR_USER) - cy.visit(COPYING_A_PROJECT_URL) - cy.findByRole('heading', { name: 'Copying a project' }) - cy.findByRole('link', { - name: '2 How to copy a project (option 2)', - }).click() - cy.url().should('contain', COPYING_A_PROJECT_URL) - cy.findByRole('heading', { - name: 'How to copy a project (option 2)', - }).should('be.visible') - }) - }) - - describe('disabled in Pro', function () { - if (isExcludedBySharding('PRO_DEFAULT_1')) return - startWith({ pro: true }) - checkDisabled() - }) - - describe('unavailable in CE', function () { - if (isExcludedBySharding('CE_CUSTOM_1')) return - startWith({ - pro: false, - vars: { - OVERLEAF_PROXY_LEARN: 'true', - }, - }) - - checkDisabled() - }) - - function checkDisabled() { - it('should not add a documentation entry to the nav bar', function () { - login(REGULAR_USER) - cy.visit('/project') - cy.findByRole('navigation', { name: 'Primary' }) - .findByRole('menuitem', { - name: 'Documentation', - }) - .should('not.exist') - }) - - it('should not render wiki page', function () { - login(REGULAR_USER) - cy.visit(COPYING_A_PROJECT_URL, { - failOnStatusCode: false, - }) - cy.findByRole('heading', { name: 'Not found' }) - }) - - it('should not display a tutorial link in the welcome page', function () { - login(WITHOUT_PROJECTS_USER) - cy.visit('/project') - cy.findByText(LABEL_LEARN_LATEX).should('not.exist') - }) - } -}) diff --git a/server-ce/test/package.json b/server-ce/test/package.json deleted file mode 100644 index 1e16326d10..0000000000 --- a/server-ce/test/package.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "@overleaf/server-ce-test", - "description": "e2e tests for Overleaf Community Edition", - "private": true, - "type": "module", - "scripts": { - "cypress:open": "cypress open --e2e --browser chrome", - "cypress:run": "cypress run --e2e --browser chrome", - "lint": "eslint --cache --cache-location ../../.cache/eslint/ --max-warnings 0 --format unix --ext .js,.jsx,.mjs,.ts,.tsx .", - "lint:fix": "eslint --cache --cache-location ../../.cache/eslint/ --fix --ext .js,.jsx,.mjs,.ts,.tsx ." - }, - "devDependencies": { - "@isomorphic-git/lightning-fs": "^4.6.0", - "@overleaf/promise-utils": "workspace:*", - "@overleaf/validation-tools": "workspace:*", - "@testing-library/cypress": "10.1.0", - "@types/adm-zip": "^0.5.7", - "@types/uuid": "^9.0.8", - "adm-zip": "^0.5.12", - "body-parser": "1.20.4", - "cypress": "15.12.0", - "cypress-multi-reporters": "^2.0.5", - "express": "4.22.1", - "isomorphic-git": "^1.33.1", - "js-yaml": "^4.1.1", - "mocha-junit-reporter": "^2.2.1", - "pdfjs-dist": "^5.1.91", - "uuid": "^9.0.1", - "zod-validation-error": "^4.0.1" - } -} diff --git a/server-ce/test/project-list.spec.ts b/server-ce/test/project-list.spec.ts deleted file mode 100644 index fb61cc9012..0000000000 --- a/server-ce/test/project-list.spec.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { ensureUserExists, login } from './helpers/login' -import { createProject } from './helpers/project' -import { isExcludedBySharding, startWith } from './helpers/config' -import { v4 as uuid } from 'uuid' - -const WITHOUT_PROJECTS_USER = 'user-without-projects@example.com' -const REGULAR_USER = 'user@example.com' - -describe('Project List', function () { - if (isExcludedBySharding('PRO_DEFAULT_2')) return - startWith({ pro: true }) - - const findProjectRow = (projectName: string) => { - cy.log('find project row') - return cy.findByText(projectName).parent().parent() - } - - describe('user with no projects', function () { - ensureUserExists({ email: WITHOUT_PROJECTS_USER }) - - it("'Import from GitHub' is not displayed in the welcome page", function () { - login(WITHOUT_PROJECTS_USER) - cy.visit('/project') - cy.findByRole('button', { name: 'Create a new project' }).click() - cy.findByRole('menuitem', { name: 'Import from GitHub' }).should( - 'not.exist' - ) - }) - }) - - describe('user with projects', function () { - const projectName = `test-project-${uuid()}` - ensureUserExists({ email: REGULAR_USER }) - - before(function () { - login(REGULAR_USER) - createProject(projectName, { type: 'Example project', open: false }) - }) - beforeEach(function () { - login(REGULAR_USER) - cy.visit('/project') - }) - - it('Can download project sources', function () { - findProjectRow(projectName).within(() => - cy.findByRole('button', { name: 'Download .zip file' }).click() - ) - - const zipName = projectName.replaceAll('-', '_') - cy.task('readFileInZip', { - pathToZip: `cypress/downloads/${zipName}.zip`, - fileToRead: 'main.tex', - }).should('contain', 'Your introduction goes here') - }) - - it('Can download project PDF', function () { - findProjectRow(projectName).within(() => - cy.findByRole('button', { name: 'Download PDF' }).click() - ) - - const pdfName = projectName.replaceAll('-', '_') - cy.task('readPdf', `cypress/downloads/${pdfName}.pdf`).should( - 'contain', - 'Your introduction goes here' - ) - }) - - it('can assign and remove tags to projects', function () { - const tagName = uuid().slice(0, 7) // long tag names are truncated in the UI, which affects selectors - cy.log('select project') - cy.findByRole('checkbox', { name: `Select ${projectName}` }).check() - - cy.log('add tag to project') - cy.findByRole('button', { name: 'Tags' }).click() - cy.findByRole('menuitem', { name: 'Create new tag' }).click() - cy.findByRole('dialog').within(() => { - cy.findByRole('heading', { name: 'Create new tag' }) - cy.findByLabelText('New tag name').type(`${tagName}{enter}`) - }) - cy.findByRole('button', { name: `Select tag ${tagName}` }) // tag label in project row - - cy.log('remove tag') - cy.findByRole('button', { name: `Remove tag ${tagName}` }) - .first() - .click() - cy.findByRole('button', { name: `Select tag ${tagName}` }).should( - 'not.exist' - ) - }) - - it('can filter by tag', function () { - cy.log('create a separate project to filter') - const nonTaggedProjectName = `project-${uuid()}` - createProject(nonTaggedProjectName, { open: false }) - - cy.log('select project') - cy.findByRole('checkbox', { name: `Select ${projectName}` }).check() - - cy.log('add tag to project') - const tagName = uuid().slice(0, 7) // long tag names are truncated in the UI, which affects selectors - cy.findByRole('button', { name: 'Tags' }).click() - cy.findByRole('menuitem', { name: 'Create new tag' }).click() - - cy.findByRole('dialog').within(() => { - cy.findByRole('heading', { name: 'Create new tag' }) - cy.findByLabelText('New tag name').type(`${tagName}{enter}`) - }) - - cy.log( - 'check the non-tagged project is filtered out after clicking the tag' - ) - cy.findByRole('link', { name: nonTaggedProjectName }).should('exist') - cy.findByRole('button', { name: `Select tag ${tagName}` }).click() - cy.findByRole('link', { name: nonTaggedProjectName }).should('not.exist') - }) - - it('can change theme from the account menu', function () { - cy.findByRole('menuitem', { name: 'Account' }).click() - cy.findByLabelText('Dark').click() - cy.get('body').should('have.attr', 'data-theme', 'default') - cy.findByLabelText('Light').click() - cy.get('body').should('have.attr', 'data-theme', 'light') - }) - }) -}) diff --git a/server-ce/test/project-sharing.spec.ts b/server-ce/test/project-sharing.spec.ts deleted file mode 100644 index b09483703c..0000000000 --- a/server-ce/test/project-sharing.spec.ts +++ /dev/null @@ -1,540 +0,0 @@ -import { v4 as uuid } from 'uuid' -import { - isExcludedBySharding, - startWith, - reloadWith, - STARTUP_TIMEOUT, -} from './helpers/config' -import { ensureUserExists, login } from './helpers/login' -import { - createProjectAndOpenInNewEditor, - enableLinkSharing, - getSpamSafeProjectName, - openProjectByName, - openProjectViaLinkSharingAsAnon, - openProjectViaLinkSharingAsUser, - shareProjectByEmailAndAcceptInviteViaDash, - shareProjectByEmailAndAcceptInviteViaEmail, -} from './helpers/project' -import { prepareWaitForNextCompileSlot } from './helpers/compile' -import { beforeWithReRunOnTestRetry } from './helpers/beforeWithReRunOnTestRetry' - -describe('Project Sharing', function () { - if (isExcludedBySharding('PRO_CUSTOM_4')) return - startWith({ withDataDir: true, pro: true, resetData: true }) - ensureUserExists({ email: 'user@example.com' }) - - let projectName: string - let recompile: () => void - let waitForCompile: (triggerCompile: () => void) => void - - // Link-sharing urls have to be created before disabling link sharing. - // We use the `beforeEach` hook to reload the server with link sharing - // disabled **after** the initial setup which happens in the `before` - // block. The `before` hook always runs prior to the `beforeEach` hook. - - // Set up retained access before disabling link sharing - beforeWithReRunOnTestRetry(() => { - // undo the link sharing restriction before joining via link-sharing below - cy.wrap(reloadWith({ withDataDir: true, pro: true }), { - timeout: STARTUP_TIMEOUT, - }) - - projectName = getSpamSafeProjectName() - ;({ recompile, waitForCompile } = prepareWaitForNextCompileSlot()) - setupTestProject() - }) - - beforeEach(function () { - // Always start with a fresh session - cy.session([uuid()], () => {}) - }) - - let linkSharingReadOnly: string - let linkSharingReadAndWrite: string - - function setupTestProject() { - login('user@example.com') - waitForCompile(() => { - createProjectAndOpenInNewEditor(projectName) - }) - - // Add chat message - cy.findByRole('tab', { name: 'Chat' }).click() - // wait for lazy loading of the chat pane - cy.findByText('Start the conversation by saying hello or sharing an update') - cy.get( - 'textarea[placeholder="Send a message to your collaborators…"]' - ).type('New Chat Message{enter}') - - // Get link sharing links - enableLinkSharing().then( - ({ linkSharingReadOnly: ro, linkSharingReadAndWrite: rw }) => { - linkSharingReadAndWrite = rw - linkSharingReadOnly = ro - } - ) - } - - function expectContentReadOnlyAccess() { - cy.url().should('match', /\/project\/[a-fA-F0-9]{24}/) - cy.findByRole('textbox', { name: 'Source Editor editing' }).should( - 'contain.text', - '\\maketitle' - ) - cy.findByRole('textbox', { name: 'Source Editor editing' }).should( - 'have.attr', - 'contenteditable', - 'false' - ) - } - - function expectContentWriteAccess() { - const section = `Test Section ${uuid()}` - cy.url().should('match', /\/project\/[a-fA-F0-9]{24}/) - // wait for the editor to finish loading - cy.findByRole('textbox', { name: 'Source Editor editing' }).should( - 'contain.text', - '\\maketitle' - ) - // the editor should be writable - cy.findByRole('textbox', { name: 'Source Editor editing' }).should( - 'have.attr', - 'contenteditable', - 'true' - ) - cy.findByRole('textbox', { name: 'Source Editor editing' }).within(() => { - cy.findByText('\\maketitle').parent().click() - cy.findByText('\\maketitle').parent().type(`\n\\section{{}${section}}`) - }) - // should have written - cy.findByRole('textbox', { name: 'Source Editor editing' }).should( - 'contain.text', - `\\section{${section}}` - ) - // check PDF - recompile() - cy.findByRole('region', { name: 'PDF preview' }).within(() => { - cy.findByLabelText(/Page.*1/i).should('be.visible') - cy.findByText(projectName).should('be.visible') - }) - cy.findByRole('region', { name: 'PDF preview' }).within(() => { - cy.findByLabelText(/Page.*1/i).should('be.visible') - cy.contains(section) - }) - } - - function expectNoAccess() { - // try read only access link - cy.visit(linkSharingReadOnly) - cy.url().should('match', /\/login/) - - // Cypress bugs: cypress resolves the link-sharing link outside the browser, and it carries over the hash of the link-sharing link to the login page redirect (bug 1). - // Effectively, cypress then instructs the browser to change the page from /login#read-only-hash to /login#read-and-write-hash. - // This is turn does not trigger a "page load", but rather just "scrolling", which in turn trips up the "page loaded" detection in cypress (bug 2). - // Work around this by navigating away from the /login page in between checks. - cy.visit('/user/password/reset') - - // try read and write access link - cy.visit(linkSharingReadAndWrite) - cy.url().should('match', /\/login/) - } - - function expectChatAccess() { - cy.findByRole('tab', { name: 'Chat' }).click() - cy.findByText('New Chat Message') - } - - function expectHistoryAccess() { - cy.findByRole('button', { name: 'History' }).click() - // The input is not clickable due to being visually hidden, click its label instead - cy.findByRole('complementary', { - name: 'Project history and labels', - }).within(() => { - cy.findByRole('group', { - name: 'Show all of the project history or only labelled versions.', - }).within(() => { - cy.findByText('All history').click() - }) - cy.findByRole('radio', { name: 'Labels' }).should('not.be.checked') - cy.findByRole('radio', { name: 'All history' }).should('be.checked') - }) - cy.findByText(/\\begin\{document}/) - cy.findByRole('complementary', { - name: 'Project history and labels', - }).within(() => { - cy.findAllByTestId('history-version-metadata-users') - .last() - .should('have.text', 'user') - }) - cy.findByRole('button', { name: 'Back to editor' }).click() - } - - function expectNoChatAccess() { - cy.findByRole('button', { name: 'Layout options' }) // wait for lazy loading - cy.findByRole('tab', { name: 'Chat' }).should('not.exist') - } - - function expectNoHistoryAccess() { - cy.findByRole('button', { name: 'Layout options' }) // wait for lazy loading - cy.findByRole('button', { name: 'History' }).should('not.exist') - } - - function expectCommentAccess() { - cy.findByRole('textbox', { name: 'Source Editor editing' }).should( - 'contain.text', - '\\maketitle' - ) - - cy.findByText('\\maketitle').parent().dblclick() - - cy.findByRole('button', { name: 'Add comment' }).should('be.visible') - - cy.findByRole('textbox', { name: 'Source Editor editing' }).click() - } - - function expectNoCommentAccess() { - cy.findByRole('textbox', { name: 'Source Editor editing' }).should( - 'contain.text', - '\\maketitle' - ) - - cy.findByText('\\maketitle').parent().dblclick() - - cy.findByRole('button', { name: 'Add comment' }).should('not.exist') - cy.findByRole('textbox', { name: 'Source Editor editing' }).click() - } - - function expectFullReadOnlyAccess() { - expectContentReadOnlyAccess() - expectChatAccess() - expectHistoryAccess() - expectNoCommentAccess() - } - - function expectRestrictedReadOnlyAccess() { - expectContentReadOnlyAccess() - expectNoChatAccess() - expectNoHistoryAccess() - expectNoCommentAccess() - } - - function expectFullReadAndWriteAccess() { - expectContentWriteAccess() - expectChatAccess() - expectHistoryAccess() - expectCommentAccess() - } - - function expectAnonymousReadAndWriteAccess() { - expectContentWriteAccess() - expectChatAccess() - expectHistoryAccess() - expectNoCommentAccess() - } - - function expectProjectDashboardEntry() { - cy.visit('/project') - cy.findByText(projectName) - } - - function expectEditAuthoredAs(author: string) { - cy.findByRole('button', { name: 'History' }).click() - cy.findByRole('complementary', { - name: 'Project history and labels', - }).within(() => { - cy.findAllByTestId('history-version-metadata-users') - .first() - .should('contain.text', author) // might have other edits in the same group - }) - } - describe('via email', function () { - const email = 'collaborator-email@example.com' - ensureUserExists({ email }) - - beforeEach(function () { - login('user@example.com') - shareProjectByEmailAndAcceptInviteViaEmail( - projectName, - email, - 'Viewer', - true - ) - }) - - it('should grant the collaborator read access', function () { - expectFullReadOnlyAccess() - expectProjectDashboardEntry() - }) - }) - - describe('read only', function () { - const email = 'collaborator-ro@example.com' - ensureUserExists({ email }) - - beforeWithReRunOnTestRetry(() => { - login('user@example.com') - shareProjectByEmailAndAcceptInviteViaDash( - projectName, - email, - 'Viewer', - true - ) - }) - - it('should grant the collaborator read access', function () { - login(email) - openProjectByName(projectName, true) - expectFullReadOnlyAccess() - expectProjectDashboardEntry() - }) - }) - - describe('read and write', function () { - const email = 'collaborator-rw@example.com' - ensureUserExists({ email }) - - beforeWithReRunOnTestRetry(() => { - login('user@example.com') - shareProjectByEmailAndAcceptInviteViaDash( - projectName, - email, - 'Editor', - true - ) - }) - - it('should grant the collaborator write access', function () { - login(email) - openProjectByName(projectName, true) - expectFullReadAndWriteAccess() - expectEditAuthoredAs('You') - expectProjectDashboardEntry() - }) - }) - - describe('token access', function () { - describe('logged in', function () { - describe('read only', function () { - const email = 'collaborator-link-ro@example.com' - ensureUserExists({ email }) - - it('should grant restricted read access', function () { - login(email) - openProjectViaLinkSharingAsUser( - linkSharingReadOnly, - projectName, - email, - true - ) - expectRestrictedReadOnlyAccess() - expectProjectDashboardEntry() - }) - }) - - describe('read and write', function () { - const email = 'collaborator-link-rw@example.com' - ensureUserExists({ email }) - - it('should grant full write access', function () { - login(email) - openProjectViaLinkSharingAsUser( - linkSharingReadAndWrite, - projectName, - email, - true - ) - expectFullReadAndWriteAccess() - expectEditAuthoredAs('You') - expectProjectDashboardEntry() - }) - }) - }) - - describe('with OVERLEAF_ALLOW_PUBLIC_ACCESS=false', function () { - describe('wrap startup', function () { - startWith({ - pro: true, - vars: { - OVERLEAF_ALLOW_PUBLIC_ACCESS: 'false', - }, - withDataDir: true, - }) - it('should block access', function () { - expectNoAccess() - }) - }) - - describe('with OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING=true', function () { - startWith({ - pro: true, - vars: { - OVERLEAF_ALLOW_PUBLIC_ACCESS: 'false', - OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING: 'true', - }, - withDataDir: true, - }) - it('should block access', function () { - expectNoAccess() - }) - }) - }) - - describe('with OVERLEAF_ALLOW_PUBLIC_ACCESS=true', function () { - describe('wrap startup', function () { - startWith({ - pro: true, - vars: { - OVERLEAF_ALLOW_PUBLIC_ACCESS: 'true', - }, - withDataDir: true, - }) - it('should grant read access with read link', function () { - openProjectViaLinkSharingAsAnon(linkSharingReadOnly, true) - expectRestrictedReadOnlyAccess() - }) - - it('should prompt for login with write link', function () { - cy.visit(linkSharingReadAndWrite) - cy.url().should('match', /\/login/) - }) - }) - - describe('with OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING=true', function () { - startWith({ - pro: true, - vars: { - OVERLEAF_ALLOW_PUBLIC_ACCESS: 'true', - OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING: 'true', - }, - withDataDir: true, - }) - - it('should grant read access with read link', function () { - openProjectViaLinkSharingAsAnon(linkSharingReadOnly, true) - expectRestrictedReadOnlyAccess() - }) - - it('should grant write access with write link', function () { - openProjectViaLinkSharingAsAnon(linkSharingReadAndWrite, true) - expectAnonymousReadAndWriteAccess() - expectEditAuthoredAs('Anonymous') - }) - }) - }) - - describe('with OVERLEAF_DISABLE_LINK_SHARING=true', function () { - const email = 'collaborator-email@example.com' - ensureUserExists({ email }) - - const invitedEmail = 'invited-email@example.com' - ensureUserExists({ email: invitedEmail }) - - const retainedViewerEmail = 'collaborator-retained-viewer@example.com' - ensureUserExists({ email: retainedViewerEmail }) - - const retainedEditorEmail = 'collaborator-retained-editor@example.com' - ensureUserExists({ email: retainedEditorEmail }) - - beforeWithReRunOnTestRetry(() => { - // Set up retained viewer access - login(retainedViewerEmail) - openProjectViaLinkSharingAsUser( - linkSharingReadOnly, - projectName, - retainedViewerEmail, - true - ) - - // Set up retained editor access - login(retainedEditorEmail) - openProjectViaLinkSharingAsUser( - linkSharingReadAndWrite, - projectName, - retainedEditorEmail, - true - ) - }) - - beforeEach(function () { - this.timeout(STARTUP_TIMEOUT) // Increase timeout for server reload - - return cy.wrap( - reloadWith({ - pro: true, - vars: { - OVERLEAF_ALLOW_PUBLIC_ACCESS: 'true', - OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING: 'true', - OVERLEAF_DISABLE_LINK_SHARING: 'true', - }, - withDataDir: true, - }), - { timeout: STARTUP_TIMEOUT } - ) - }) - - it('should not display link sharing in the sharing modal', function () { - login('user@example.com') - openProjectByName(projectName, true) - cy.findByRole('navigation', { - name: 'Project actions', - }) - .findByRole('button', { name: 'Share' }) - .click() - cy.findByRole('button', { name: 'Turn on link sharing' }).should( - 'not.exist' - ) - }) - - it('should block new access to read-only link shared projects', function () { - login(email) - - // Test read-only link returns 404 - cy.request({ - url: linkSharingReadOnly, - failOnStatusCode: false, - }).then(response => { - expect(response.status).to.eq(404) - }) - }) - - it('should block new access to read-write link shared projects', function () { - login(email) - - // Test read-write link returns 404 - cy.request({ - url: linkSharingReadAndWrite, - failOnStatusCode: false, - }).then(response => { - expect(response.status).to.eq(404) - }) - }) - - it('should continue to allow email sharing', function () { - login('user@example.com') - shareProjectByEmailAndAcceptInviteViaEmail( - projectName, - invitedEmail, - 'Viewer', - true - ) - expectFullReadOnlyAccess() - expectProjectDashboardEntry() - }) - - it('should retain read-only access when project was joined via link before link sharing was turned off', function () { - login(retainedViewerEmail) - openProjectByName(projectName, true) - expectRestrictedReadOnlyAccess() - expectProjectDashboardEntry() - }) - - it('should retain read-write access when project was joined via link before link sharing was turned off', function () { - login(retainedEditorEmail) - openProjectByName(projectName, true) - expectFullReadAndWriteAccess() - expectProjectDashboardEntry() - }) - }) - }) -}) diff --git a/server-ce/test/sandboxed-compiles.spec.ts b/server-ce/test/sandboxed-compiles.spec.ts deleted file mode 100644 index 252075fefd..0000000000 --- a/server-ce/test/sandboxed-compiles.spec.ts +++ /dev/null @@ -1,351 +0,0 @@ -import { ensureUserExists, login } from './helpers/login' -import { createProjectAndOpenInNewEditor } from './helpers/project' -import { isExcludedBySharding, startWith } from './helpers/config' -import { prepareWaitForNextCompileSlot, stopCompile } from './helpers/compile' -import { v4 as uuid } from 'uuid' -import { waitUntilScrollingFinished } from './helpers/waitUntilScrollingFinished' - -const LABEL_TEX_LIVE_VERSION = 'TeX Live version' - -describe('SandboxedCompiles', function () { - const enabledVars = { - SANDBOXED_COMPILES: 'true', - ALL_TEX_LIVE_DOCKER_IMAGE_NAMES: '2023,2022', - } - - describe('enabled in Server Pro', function () { - if (isExcludedBySharding('PRO_CUSTOM_2')) return - startWith({ - pro: true, - vars: enabledVars, - resetData: true, - }) - ensureUserExists({ email: 'user@example.com' }) - beforeEach(function () { - login('user@example.com') - }) - - it('should offer TexLive images and switch the compiler', function () { - const { recompile, waitForCompile } = prepareWaitForNextCompileSlot() - waitForCompile(() => { - createProjectAndOpenInNewEditor('sandboxed') - }) - cy.log('wait for compile') - cy.findByRole('region', { name: 'PDF preview' }).should( - 'contain.text', - 'sandboxed' - ) - cy.findByRole('button', { name: 'View logs' }).click() - cy.findByLabelText('Raw logs from the LaTeX compiler').within(() => { - cy.log('Check which compiler version was used, expect 2023') - cy.findByRole('button', { name: 'Expand' }).click() - cy.findByText(/This is pdfTeX, Version .+ \(TeX Live 2023\) /) - }) - - cy.log('Switch TeXLive version from 2023 to 2022') - cy.findByRole('button', { name: 'Settings' }).click() - cy.findByRole('dialog').findByRole('tab', { name: 'Compiler' }).click() - cy.findByRole('dialog').within(() => { - cy.findByRole('option', { name: '2023' }).should('be.selected') - cy.findByRole('combobox', { - name: LABEL_TEX_LIVE_VERSION, - }).select('2022') - }) - cy.get('body').type('{esc}') - cy.findByRole('dialog').should('not.exist') - cy.log('Trigger compile with other TeX Live version') - recompile() - - cy.findByRole('button', { name: 'View logs' }).click() - cy.findByLabelText('Raw logs from the LaTeX compiler').within(() => { - cy.log('Check which compiler version was used, expect 2022') - cy.findByRole('button', { name: 'Expand' }).click() - cy.findByText(/This is pdfTeX, Version .+ \(TeX Live 2022\) /) - }) - }) - - checkSyncTeX() - checkXeTeX() - checkRecompilesAfterErrors() - checkStopCompile() - }) - - function checkStopCompile() { - it('users can stop a running compile', function () { - login('user@example.com') - const { recompile, waitForCompile, waitForCompileRateLimitCoolOff } = - prepareWaitForNextCompileSlot() - waitForCompile(() => { - createProjectAndOpenInNewEditor('test-project') - }) - // create an infinite loop in the main document - // this will cause the compile to run indefinitely - cy.findByText('\\maketitle').parent().click() - cy.findByText('\\maketitle') - .parent() - .type( - '\nFirst page\\clearpage' + - '\nSecond page' + - '\\def\\loop{{}\\let\\next\\loop\\next}\\loop' - ) - waitForCompileRateLimitCoolOff() - cy.log('Start compile') - // We need to start the compile manually because we do not want to wait for it to finish - cy.findByRole('button', { name: 'Recompile' }).click() - // Wait for the compile to start - cy.findByRole('button', { name: 'Compiling…' }).should('exist') - // Now stop the compile and kill the latex process - stopCompile({ delay: 1000 }) - cy.findByRole('region', { name: 'PDF preview' }) - .invoke('text') - .should('match', /Compilation cancelled/) - // Check that the previous compile is not running in the background by - // disabling the infinite loop and recompiling - cy.findByText('\\def').parent().click() - cy.findByText('\\def').parent().type('{home}disabled loop% ') - recompile() - cy.findByRole('region', { name: 'PDF preview' }) - .should('contain.text', 'disabled loop') - .should('not.contain.text', 'A previous compile is still running') - }) - } - - function checkSyncTeX() { - describe('SyncTeX', function () { - let projectName: string - beforeEach(function () { - projectName = `Project ${uuid()}` - const { recompile, waitForCompile } = prepareWaitForNextCompileSlot() - waitForCompile(() => { - createProjectAndOpenInNewEditor(projectName) - }) - cy.findByRole('textbox', { name: 'Source Editor editing' }).within( - () => { - cy.findByText('\\maketitle').parent().click() - cy.findByText('\\maketitle') - .parent() - .type( - `\n\\pagebreak\n\\section{{}Section A}\n\\pagebreak\n\\section{{}Section B}\n\\pagebreak` - ) - } - ) - recompile() - cy.log('wait for pdf-rendering') - cy.findByRole('region', { name: 'PDF preview' }).findByText(projectName) - }) - - it('should sync to code', function () { - cy.log('navigate to \\maketitle using double click in PDF') - cy.findByRole('region', { name: 'PDF preview' }) - .findByText(projectName) - .dblclick() - cy.get('.cm-activeLine').should('have.text', '\\maketitle') - - cy.log('navigate to Section A using double click in PDF') - cy.findByRole('region', { name: 'PDF preview' }) - .findByText('Section A') - .dblclick() - cy.get('.cm-activeLine').should('have.text', '\\section{Section A}') - - cy.log('navigate to Section B using arrow button') - cy.findByTestId('pdfjs-viewer-inner') - .should('have.prop', 'scrollTop') - .as('start') - cy.findByRole('region', { name: 'PDF preview' }) - .findByText('Section B') - .scrollIntoView() - cy.get('@start').then((start: any) => { - waitUntilScrollingFinished( - '[data-testid="pdfjs-viewer-inner"]', - start - ) - }) - // The sync button is swapped as the position in the PDF changes. - // Cypress appears to click on a button that references a stale position. - // Adding a cy.wait() statement is the most reliable "fix" so far :/ - // eslint-disable-next-line cypress/no-unnecessary-waiting - cy.wait(1000) - cy.findByRole('button', { - name: 'Go to PDF location in code (Tip: double click on the PDF for best results)', - }).click() - cy.get('.cm-activeLine').should('have.text', '\\section{Section B}') - }) - - it('should sync to pdf', function () { - cy.log('zoom in') - cy.findByRole('button', { name: 'PDF zoom level' }).click() - cy.findByRole('menuitem', { name: '400%' }).click() - cy.log('scroll to top') - cy.findByTestId('pdfjs-viewer-inner').scrollTo('top') - waitUntilScrollingFinished('[data-testid="pdfjs-viewer-inner"]', -1).as( - 'start' - ) - - cy.log('navigate to title') - cy.findByRole('textbox', { name: 'Source Editor editing' }).within( - () => { - cy.findByText('\\maketitle').parent().click() - } - ) - cy.findByRole('button', { name: 'Go to code location in PDF' }).click() - cy.get('@start').then((start: any) => { - waitUntilScrollingFinished( - '[data-testid="pdfjs-viewer-inner"]', - start - ) - .as('title') - .should('be.greaterThan', start) - }) - - cy.log('navigate to Section A') - cy.findByRole('textbox', { name: 'Source Editor editing' }).within(() => - cy.findByText('Section A').click() - ) - cy.findByRole('button', { name: 'Go to code location in PDF' }).click() - cy.get('@title').then((title: any) => { - waitUntilScrollingFinished( - '[data-testid="pdfjs-viewer-inner"]', - title - ) - .as('sectionA') - .should('be.greaterThan', title) - }) - - cy.log('navigate to Section B') - cy.findByRole('textbox', { name: 'Source Editor editing' }).within(() => - cy.findByText('Section B').click() - ) - cy.findByRole('button', { name: 'Go to code location in PDF' }).click() - cy.get('@sectionA').then((title: any) => { - waitUntilScrollingFinished( - '[data-testid="pdfjs-viewer-inner"]', - title - ) - .as('sectionB') - .should('be.greaterThan', title) - }) - }) - }) - } - - function checkRecompilesAfterErrors() { - it('recompiles even if there are Latex errors', function () { - login('user@example.com') - const { recompile, waitForCompile } = prepareWaitForNextCompileSlot() - waitForCompile(() => { - createProjectAndOpenInNewEditor('test-project') - }) - cy.findByRole('textbox', { name: 'Source Editor editing' }).within(() => { - cy.findByText('\\maketitle').parent().click() - cy.findByText('\\maketitle') - .parent() - .type('\n\\fakeCommand{} \n\\section{{}Test Section}') - }) - recompile() - recompile() - cy.findByRole('region', { name: 'PDF preview' }) - .findByText('Test Section') - .should('not.contain.text', 'No PDF') - }) - } - - function checkXeTeX() { - it('should be able to use XeLaTeX', function () { - const { recompile, waitForCompile } = prepareWaitForNextCompileSlot() - waitForCompile(() => { - createProjectAndOpenInNewEditor('XeLaTeX') - }) - cy.log('wait for compile') - cy.findByRole('region', { name: 'PDF preview' }).findByText('XeLaTeX') - cy.log('Check which compiler was used, expect pdfLaTeX') - cy.findByRole('button', { name: 'View logs' }).click() - cy.findByLabelText('Raw logs from the LaTeX compiler').within(() => { - cy.log('Check which compiler was used, expect pdfLaTeX') - cy.findByRole('button', { name: 'Expand' }).click() - cy.findByText(/This is pdfTeX/) - }) - - cy.log('Switch compiler to from pdfLaTeX to XeLaTeX') - cy.findByRole('button', { name: 'Settings' }).click() - cy.findByRole('dialog').findByRole('tab', { name: 'Compiler' }).click() - cy.findByRole('dialog').within(() => { - cy.findByRole('option', { name: 'pdfLaTeX' }).should('be.selected') - cy.findByRole('combobox', { - name: 'Compiler', - }).select('XeLaTeX') - }) - - cy.get('body').type('{esc}') - cy.findByRole('dialog').should('not.exist') - - cy.log('Trigger compile with other compiler') - recompile() - - cy.findByRole('button', { name: 'View logs' }).click() - cy.findByLabelText('Raw logs from the LaTeX compiler').within(() => { - cy.log('Check which compiler was used, expect XeLaTeX') - cy.findByRole('button', { name: 'Expand' }).click() - cy.findByText(/This is XeTeX/) - }) - }) - } - - function checkUsesDefaultCompiler() { - beforeEach(function () { - login('user@example.com') - }) - - it('should not offer TexLive images and use default compiler', function () { - createProjectAndOpenInNewEditor('sandboxed') - cy.log('wait for compile') - cy.findByRole('region', { name: 'PDF preview' }).findByText('sandboxed') - - cy.log('Check which compiler version was used, expect 2026') - cy.findByRole('button', { name: 'View logs' }).click() - cy.findByLabelText('Raw logs from the LaTeX compiler').within(() => { - cy.findByRole('button', { name: 'Expand' }).click() - cy.findByText(/This is pdfTeX, Version .+ \(TeX Live 2026\) /) - }) - - cy.log('Check that there is no TeX Live version toggle') - cy.findByRole('button', { name: 'Settings' }).click() - cy.findByRole('dialog').findByRole('tab', { name: 'Compiler' }).click() - cy.findByRole('dialog').within(() => { - cy.findByText('Main document').should('exist') - cy.findByText(LABEL_TEX_LIVE_VERSION).should('not.exist') - }) - }) - } - - describe('disabled in Server Pro', function () { - if (isExcludedBySharding('PRO_DEFAULT_2')) return - startWith({ pro: true }) - ensureUserExists({ email: 'user@example.com' }) - beforeEach(function () { - login('user@example.com') - }) - - checkUsesDefaultCompiler() - checkSyncTeX() - checkXeTeX() - checkRecompilesAfterErrors() - checkStopCompile() - }) - - // https://github.com/overleaf/internal/issues/20216 - // eslint-disable-next-line mocha/no-pending-tests - describe.skip('unavailable in CE', function () { - if (isExcludedBySharding('CE_CUSTOM_1')) return - startWith({ pro: false, vars: enabledVars, resetData: true }) - ensureUserExists({ email: 'user@example.com' }) - beforeEach(function () { - login('user@example.com') - }) - - checkUsesDefaultCompiler() - checkSyncTeX() - checkXeTeX() - checkRecompilesAfterErrors() - checkStopCompile() - }) -}) diff --git a/server-ce/test/templates.spec.ts b/server-ce/test/templates.spec.ts deleted file mode 100644 index dc7eaa6649..0000000000 --- a/server-ce/test/templates.spec.ts +++ /dev/null @@ -1,281 +0,0 @@ -import { isExcludedBySharding, startWith } from './helpers/config' -import { ensureUserExists, login } from './helpers/login' -import { - createProjectAndOpenInNewEditor, - NEW_PROJECT_BUTTON_MATCHER, - redirectEditorUrlWithQueryParams, -} from './helpers/project' - -const WITHOUT_PROJECTS_USER = 'user-without-projects@example.com' -const ADMIN_USER = 'admin@example.com' -const REGULAR_USER = 'user@example.com' -const TEMPLATES_USER = 'templates@example.com' - -// Re-use value for "exists" and "does not exist" tests -const LABEL_BROWSE_TEMPLATES = 'Browse templates' - -describe('Templates', function () { - ensureUserExists({ email: TEMPLATES_USER }) - ensureUserExists({ email: WITHOUT_PROJECTS_USER }) - - let OVERLEAF_TEMPLATES_USER_ID: string - before(function () { - login(TEMPLATES_USER) - cy.visit('/') - cy.get('meta[name="ol-user_id"]').then(el => { - OVERLEAF_TEMPLATES_USER_ID = el.attr('content')! - }) - }) - - function varsFn() { - return { - OVERLEAF_TEMPLATES_USER_ID, - OVERLEAF_NEW_PROJECT_TEMPLATE_LINKS: - '[{"name":"All Templates","url":"/templates/all"}]', - } - } - - describe('enabled in Server Pro', function () { - if (isExcludedBySharding('PRO_CUSTOM_2')) return - startWith({ - pro: true, - varsFn, - }) - ensureUserExists({ email: REGULAR_USER }) - ensureUserExists({ email: ADMIN_USER, isAdmin: true }) - - it('should show templates link on welcome page', function () { - login(WITHOUT_PROJECTS_USER) - cy.visit('/') - cy.findByRole('link', { name: LABEL_BROWSE_TEMPLATES }).click() - cy.url().should('match', /\/templates$/) - }) - - it('should have templates feature', function () { - login(TEMPLATES_USER) - const name = `Template ${Date.now()}` - const description = `Template Description ${Date.now()}` - - cy.visit('/') - createProjectAndOpenInNewEditor(name, { type: 'Example project' }).as( - 'templateProjectId' - ) - - cy.findByRole('navigation', { - name: 'Project actions', - }) - .findByRole('button', { name: 'File' }) - .click() - cy.findByRole('menuitem', { name: 'Manage template' }).click() - - cy.findByLabelText('Template Description').type(description) - cy.findByRole('button', { name: 'Publish' }).click() - cy.findByRole('button', { name: 'Publishing…' }).should('be.disabled') - cy.findByRole('button', { name: 'Publish' }).should('not.exist') - cy.findByRole('button', { name: 'Unpublish', timeout: 60_000 }) - cy.findByRole('button', { name: 'Republish' }) - - cy.findByRole('link', { name: 'View it in the template gallery' }).click() - cy.url() - .should('match', /\/templates\/[a-f0-9]{24}$/) - .as('templateURL') - - cy.findByRole('heading', { level: 2 }).findByText(name) - cy.findByText(description) - cy.findByRole('link', { name: 'Open as Template' }) - cy.findByRole('button', { name: 'Unpublish' }) - cy.findByRole('button', { name: 'Republish' }) - cy.get('img') - .should('have.attr', 'src') - .and('match', /\/v\/0\//) - cy.findByRole('button', { name: 'Republish' }).click() - cy.findByRole('button', { name: 'Publishing…' }).should('be.disabled') - cy.findByRole('button', { name: 'Republish', timeout: 60_000 }) - cy.get('img', { timeout: 60_000 }) - .should('have.attr', 'src') - .and('match', /\/v\/1\//) - - // custom tag - const tagName = `${Date.now()}` - cy.visit('/') - cy.findByRole('checkbox', { name: `Select ${name}` }).check() - cy.findByRole('navigation', { name: 'Project categories and tags' }) - .findByRole('button', { name: 'New tag' }) - .click() - cy.focused().type(tagName) - cy.findByRole('button', { name: 'Create' }).click() - cy.findByRole('navigation', { - name: 'Project categories and tags', - }).should('contain', `${tagName} (1)`) - - // Check listing - cy.visit('/templates') - cy.findByRole('link', { name: tagName }) - cy.visit('/templates/all') - cy.findByRole('heading', { name }) - cy.visit(`/templates/${tagName}`) - cy.findByRole('heading', { name }) - - // Unpublish via template page - cy.get('@templateURL').then(url => cy.visit(`${url}`)) - cy.findByRole('button', { name: 'Unpublish' }).click() - cy.url().should('match', /\/templates$/) - cy.get('@templateURL').then(url => - cy.visit(`${url}`, { - failOnStatusCode: false, - }) - ) - cy.findByRole('heading', { name: 'Not found' }) - cy.visit('/templates/all') - cy.findByRole('heading', { name }).should('not.exist') - cy.visit(`/templates/${tagName}`) - cy.findByRole('heading', { name }).should('not.exist') - - // Publish again - cy.get('@templateProjectId').then(projectId => - cy.visit(`/project/${projectId}`) - ) - cy.findByRole('navigation', { - name: 'Project actions', - }) - .findByRole('button', { name: 'File' }) - .click() - cy.findByRole('menuitem', { name: 'Manage template' }).click() - cy.findByRole('button', { name: 'Publish' }).click() - cy.findByRole('button', { name: 'Unpublish', timeout: 60_000 }) - - // Should assign a new template id - cy.findByRole('link', { name: 'View it in the template gallery' }).click() - cy.url() - .should('match', /\/templates\/[a-f0-9]{24}$/) - .as('newTemplateURL') - cy.get('@newTemplateURL').then(newURL => { - cy.get('@templateURL').then(prevURL => { - expect(newURL).to.match(/\/templates\/[a-f0-9]{24}$/) - expect(prevURL).to.not.equal(newURL) - }) - }) - - // Open project from template - login(REGULAR_USER) - cy.visit('/templates') - cy.findByRole('link', { name: tagName }).click() - cy.findByRole('link', { name }).click() - cy.findByRole('link', { name: 'Open as Template' }).click() - cy.findByRole('navigation', { name: 'Project actions' }).findByText( - /Your Paper/i - ) // might have (1) suffix - cy.findByRole('navigation', { - name: 'Project actions', - }) - .findByRole('button', { name: 'File' }) - .click() - cy.findByRole('menuitem', { name: 'Word count' }).click() // wait for lazy loading - cy.findByRole('menuitem', { name: 'Manage template' }).should('not.exist') - - // Check management as regular user - cy.get('@newTemplateURL').then(url => cy.visit(`${url}`)) - cy.findByRole('link', { name: 'Open as Template' }) - cy.findByRole('button', { name: 'Unpublish' }).should('not.exist') - cy.findByRole('button', { name: 'Republish' }).should('not.exist') - - // Check management as admin user - login(ADMIN_USER) - - redirectEditorUrlWithQueryParams(true) - - cy.get('@newTemplateURL').then(url => cy.visit(`${url}`)) - cy.findByRole('link', { name: 'Open as Template' }) - cy.findByRole('button', { name: 'Unpublish' }) - cy.findByRole('button', { name: 'Republish' }) - cy.get('@templateProjectId').then(projectId => - cy.visit(`/project/${projectId}`) - ) - cy.findByRole('navigation', { - name: 'Project actions', - }) - .findByRole('button', { name: 'File' }) - .click() - cy.findByRole('menuitem', { name: 'Manage template' }).click() - cy.findByRole('button', { name: 'Unpublish' }) - - // Back to templates user - login(TEMPLATES_USER) - - // Unpublish via editor - cy.get('@templateProjectId').then(projectId => - cy.visit(`/project/${projectId}`) - ) - cy.findByRole('navigation', { - name: 'Project actions', - }) - .findByRole('button', { name: 'File' }) - .click() - cy.findByRole('menuitem', { name: 'Manage template' }).click() - cy.findByRole('button', { name: 'Unpublish' }).click() - cy.findByRole('button', { name: 'Publish' }) - cy.visit('/templates/all') - cy.findByRole('link', { name }).should('not.exist') - - // check for template links, after creating the first project - cy.visit('/') - cy.findAllByRole('button', { name: NEW_PROJECT_BUTTON_MATCHER }).click() - cy.findByRole('menuitem', { name: /All Templates/ }).should( - 'have.attr', - 'href', - '/templates/all' - ) - }) - }) - - function checkDisabled() { - it('should not have templates feature', function () { - login(TEMPLATES_USER) - - cy.visit('/') - createProjectAndOpenInNewEditor('maybe templates') - - cy.findByRole('navigation', { - name: 'Project actions', - }) - .findByRole('button', { name: 'File' }) - .click() - cy.findByRole('menuitem', { name: 'Word count' }) // wait for lazy loading - cy.findByRole('menuitem', { name: 'Manage template' }).should('not.exist') - - cy.visit('/templates', { failOnStatusCode: false }) - cy.findByRole('heading', { name: 'Not found' }) - cy.visit('/templates/all', { failOnStatusCode: false }) - cy.findByRole('heading', { name: 'Not found' }) - - // check for template links, after creating the first project - cy.visit('/') - cy.findAllByRole('button', { name: NEW_PROJECT_BUTTON_MATCHER }).click() - cy.findByRole('menuitem', { name: /All Templates/ }).should('not.exist') - }) - - it('should not show templates link on welcome page', function () { - login(WITHOUT_PROJECTS_USER) - cy.visit('/') - cy.findByRole('button', { name: NEW_PROJECT_BUTTON_MATCHER }) // wait for lazy loading - cy.findByRole('link', { name: LABEL_BROWSE_TEMPLATES }).should( - 'not.exist' - ) - }) - } - - describe('disabled Server Pro', function () { - if (isExcludedBySharding('PRO_DEFAULT_2')) return - startWith({ pro: true }) - checkDisabled() - }) - - describe('unavailable in CE', function () { - if (isExcludedBySharding('CE_CUSTOM_1')) return - startWith({ - pro: false, - varsFn, - }) - checkDisabled() - }) -}) diff --git a/server-ce/test/tsconfig.json b/server-ce/test/tsconfig.json deleted file mode 100644 index db40ae1adf..0000000000 --- a/server-ce/test/tsconfig.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "compilerOptions": { - "target": "esnext" /* Specify ECMAScript target version */, - "module": "es2020" /* Specify module code generation */, - "allowJs": true /* Allow JavaScript files to be compiled. */, - // "checkJs": true /* Report errors in .js files. */, - "jsx": "preserve" /* Specify JSX code generation */, - "noEmit": true /* Do not emit outputs. */, - "strict": true /* Enable all strict type-checking options. */, - "moduleResolution": "node" /* Specify module resolution strategy */, - "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, - "skipLibCheck": true /* Skip type checking of declaration files. */, - "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */, - "types": ["cypress", "node", "@testing-library/cypress"], - "allowImportingTsExtensions": true - }, - "include": ["**/*.ts", "**/*.js"] -} diff --git a/server-ce/test/upgrading.spec.ts b/server-ce/test/upgrading.spec.ts deleted file mode 100644 index 0938b4b51b..0000000000 --- a/server-ce/test/upgrading.spec.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { ensureUserExists, login } from './helpers/login' -import { isExcludedBySharding, startWith } from './helpers/config' -import { runScript } from './helpers/hostAdminClient' -import { createProject, openProjectByName } from './helpers/project' -import { prepareWaitForNextCompileSlot } from './helpers/compile' -import { v4 as uuid } from 'uuid' - -const USER = 'user@example.com' -const PROJECT_NAME = 'Old Project' - -describe('Upgrading', function () { - if (isExcludedBySharding('PRO_CUSTOM_3')) return - - let recompile: () => void - let waitForCompile: (triggerCompile: () => void) => void - - function testUpgrade( - steps: { - version: string - vars?: Object - newProjectButtonMatcher?: RegExp - hook?: () => void - }[] - ) { - const startOptions = steps.shift()! - - before(async function () { - cy.log('Create old instance') - }) - startWith({ - pro: true, - version: startOptions.version, - withDataDir: true, - resetData: true, - vars: startOptions.vars, - }) - before(function () { - cy.log('Create initial user after deleting it') - }) - ensureUserExists({ email: USER }) - before(function () { - cy.log('Populate old instance') - login(USER) - ;({ recompile, waitForCompile } = prepareWaitForNextCompileSlot()) - waitForCompile(() => { - createProject(PROJECT_NAME, { - newProjectButtonMatcher: startOptions.newProjectButtonMatcher, - }) - }) - - cy.log('Wait for successful compile') - cy.findByLabelText(/Page.*1/i).findByText(PROJECT_NAME) - - cy.log('Increment the doc version three times') - for (let i = 0; i < 3; i++) { - cy.log('Add content') - cy.findByText('\\maketitle').parent().click() - cy.findByText('\\maketitle') - .parent() - .type(`\n\\section{{}Old Section ${i}}`) - - cy.log('Trigger full flush') - recompile() - cy.findByRole('navigation', { - name: 'Project Layout, Sharing, and Submission', - }) - .findByRole('button', { name: 'Menu' }) - .click() - cy.findByRole('link', { name: 'Source' }).click() - cy.get('body').type('{esc}') - } - - cy.log('Check compile and history') - for (let i = 0; i < 3; i++) { - cy.findByLabelText(/Page.*1/i).findByText(`Old Section ${i}`) - } - cy.findByRole('button', { name: 'History' }).click() - for (let i = 0; i < 3; i++) { - cy.findByText(new RegExp(`\\\\section{Old Section ${i}}`)) - } - }) - - for (const step of steps) { - before(function () { - cy.log(`Upgrade to version ${step.version}`) - - // Navigate way from editor to avoid redirect to /login when the next instance comes up (which slows down tests) - cy.visit('/project', {}) - }) - before(async function () { - cy.log('Graceful shutdown: flush all the things') - this.timeout(20 * 1000) - // Ideally we could use the container shutdown procedure, but it's too slow and unreliable for tests. - // TODO(das7pad): adopt the below after speeding up the graceful shutdown procedure on all supported releases - // await dockerCompose('stop', 'sharelatex') - - // For now, we are stuck with manually flushing things - await runScript({ - cwd: 'services/document-updater', - script: 'scripts/flush_all.js', - }) - await runScript({ - cwd: 'services/project-history', - script: 'scripts/flush_all.js', - }) - }) - startWith({ - pro: true, - version: step.version, - vars: step.vars, - withDataDir: true, - }) - - step.hook?.() - } - beforeEach(function () { - login(USER) - }) - - it('should list the old project', function () { - cy.visit('/project') - cy.findByRole('link', { name: PROJECT_NAME }) - }) - - it('should open the old project', function () { - waitForCompile(() => { - openProjectByName(PROJECT_NAME) - }) - - cy.url().should('match', /\/project\/[a-fA-F0-9]{24}/) - cy.findByRole('navigation', { - name: 'Project actions', - }).within(() => { - cy.findByText(PROJECT_NAME) - }) - - cy.log('wait for successful compile') - cy.findByLabelText(/Page.*1/i).findByText(PROJECT_NAME) - cy.findByLabelText(/Page.*1/i).findByText('Old Section 2') - - cy.log('Add more content') - const newSection = `New Section ${uuid()}` - cy.findByText('\\maketitle').parent().click() - cy.findByText('\\maketitle').parent().type(`\n\\section{{}${newSection}}`) - - cy.log('Check compile and history') - recompile() - cy.findByLabelText(/Page.*1/i).findByText(newSection) - cy.findByRole('button', { name: 'History' }).click() - cy.findByText(/\\section\{Old Section 2}/) - cy.findByText(new RegExp(`\\\\section\\{${newSection}}`)) - }) - } - - const optionsFourDotTwo = { - version: '4.2', - vars: { - // Add core vars with old branding - SHARELATEX_SITE_URL: 'http://sharelatex', - SHARELATEX_MONGO_URL: 'mongodb://mongo/sharelatex', - SHARELATEX_REDIS_HOST: 'redis', - }, - newProjectButtonMatcher: /create first project/i, - } - const optionsBinaryFilesMigration = { - version: '5.5.5', - hook() { - before(async function () { - await runScript({ - cwd: 'services/history-v1', - script: 'storage/scripts/back_fill_file_hash.mjs', - args: ['--all'], - }) - }) - }, - } - describe('from 4.2 to latest', function () { - testUpgrade([ - optionsFourDotTwo, - optionsBinaryFilesMigration, - { version: 'latest' }, - ]) - }) - describe('from 5.0 to latest', function () { - testUpgrade([ - { version: '5.0' }, - optionsBinaryFilesMigration, - { version: 'latest' }, - ]) - }) -}) diff --git a/services/chat/.nvmrc b/services/chat/.nvmrc deleted file mode 100644 index 8e35034890..0000000000 --- a/services/chat/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -24.14.1 diff --git a/services/chat/Dockerfile b/services/chat/Dockerfile deleted file mode 100644 index ca3c6a25a0..0000000000 --- a/services/chat/Dockerfile +++ /dev/null @@ -1,50 +0,0 @@ -# This file was auto-generated, do not edit it directly. -# Instead run bin/update_build_scripts from -# https://github.com/overleaf/internal/ - -FROM node:24.14.1 AS base - -# Corepack setup, shared between all the images. -ENV PATH="/overleaf/node_modules/.bin:$PATH" -ENV COREPACK_HOME=/opt/corepack -RUN corepack enable && corepack install -g yarn@4.14.1 -ENV COREPACK_ENABLE_NETWORK=0 - -WORKDIR /overleaf/services/chat - -# Google Cloud Storage needs a writable $HOME/.config for resumable uploads -# (see https://googleapis.dev/nodejs/storage/latest/File.html#createWriteStream) -RUN mkdir /home/node/.config && chown node:node /home/node/.config - -FROM base AS app - -COPY package.json yarn.lock .yarnrc.yml /overleaf/ -COPY libraries/eslint-plugin/package.json /overleaf/libraries/eslint-plugin/package.json -COPY libraries/fetch-utils/package.json /overleaf/libraries/fetch-utils/package.json -COPY libraries/logger/package.json /overleaf/libraries/logger/package.json -COPY libraries/metrics/package.json /overleaf/libraries/metrics/package.json -COPY libraries/mongo-utils/package.json /overleaf/libraries/mongo-utils/package.json -COPY libraries/o-error/package.json /overleaf/libraries/o-error/package.json -COPY libraries/promise-utils/package.json /overleaf/libraries/promise-utils/package.json -COPY libraries/settings/package.json /overleaf/libraries/settings/package.json -COPY services/chat/package.json /overleaf/services/chat/package.json -COPY tools/migrations/package.json /overleaf/tools/migrations/package.json -COPY .yarn/patches/ /overleaf/.yarn/patches/ -COPY tools/migrations/ /overleaf/tools/migrations/ - -RUN cd /overleaf && yarn workspaces focus @overleaf/chat overleaf -COPY libraries/eslint-plugin/ /overleaf/libraries/eslint-plugin/ -COPY libraries/fetch-utils/ /overleaf/libraries/fetch-utils/ -COPY libraries/logger/ /overleaf/libraries/logger/ -COPY libraries/metrics/ /overleaf/libraries/metrics/ -COPY libraries/mongo-utils/ /overleaf/libraries/mongo-utils/ -COPY libraries/o-error/ /overleaf/libraries/o-error/ -COPY libraries/promise-utils/ /overleaf/libraries/promise-utils/ -COPY libraries/settings/ /overleaf/libraries/settings/ -COPY services/chat/ /overleaf/services/chat/ -COPY tools/migrations/ /overleaf/tools/migrations/ - -FROM app -USER node - -CMD ["node", "--expose-gc", "app.js"] diff --git a/services/chat/Makefile b/services/chat/Makefile deleted file mode 100644 index 9fa67f9e7a..0000000000 --- a/services/chat/Makefile +++ /dev/null @@ -1,205 +0,0 @@ -# This file was auto-generated, do not edit it directly. -# Instead run bin/update_build_scripts from -# https://github.com/overleaf/internal/ - -export BUILD_NUMBER ?= local -export BRANCH_NAME ?= $(shell git rev-parse --abbrev-ref HEAD) -export BRANCH_NAME_TAG_SAFE = $(shell echo $(BRANCH_NAME) | sed 's/\//\-\-/g' | cut -c1-96) -export PROJECT_NAME = chat -BUILD_DIR_NAME = $(shell pwd | xargs basename | tr -cd '[a-zA-Z0-9_.\-]') -HERE=$(shell pwd) -export MONOREPO ?= $(shell cd ../../ && pwd) -export RETRIES ?= $(if $(CI),3,0) -IMAGE_CI ?= ci/$(PROJECT_NAME):$(BRANCH_NAME_TAG_SAFE)-$(BUILD_NUMBER) -IMAGE_REPO ?= us-east1-docker.pkg.dev/overleaf-ops/ol-docker/$(PROJECT_NAME) -IMAGE_REPO_FINAL ?= $(IMAGE_REPO):$(BRANCH_NAME_TAG_SAFE)-$(BUILD_NUMBER) -IMAGE_CACHE ?= $(IMAGE_REPO):cache-$(shell cat \ - $(MONOREPO)/package.json \ - $(MONOREPO)/yarn.lock \ - $(MONOREPO)/libraries/eslint-plugin/package.json \ - $(MONOREPO)/libraries/fetch-utils/package.json \ - $(MONOREPO)/libraries/logger/package.json \ - $(MONOREPO)/libraries/metrics/package.json \ - $(MONOREPO)/libraries/mongo-utils/package.json \ - $(MONOREPO)/libraries/o-error/package.json \ - $(MONOREPO)/libraries/promise-utils/package.json \ - $(MONOREPO)/libraries/settings/package.json \ - $(MONOREPO)/services/chat/package.json \ - $(MONOREPO)/tools/migrations/package.json \ - $(MONOREPO)/.yarn/patches/* \ -| sha256sum | cut -d '-' -f1) -export MOCHA_GREP ?= - -DOCKER_COMPOSE_FLAGS ?= -f docker-compose.yml -DOCKER_COMPOSE := docker compose ${DOCKER_COMPOSE_FLAGS} - -# Common flags for docker compose run. -# --rm Tear down test runner container -# --build Make sure that we use the latest base image -# --quiet-build Hide build output -ifeq ($(CI),true) - DC_RUN_FLAGS := --rm -else - DC_RUN_FLAGS := --rm --build --quiet-build -endif - -COMPOSE_PROJECT_NAME_TEST_ACCEPTANCE ?= test_acceptance_$(BUILD_DIR_NAME) -DOCKER_COMPOSE_TEST_ACCEPTANCE = \ - COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME_TEST_ACCEPTANCE) $(DOCKER_COMPOSE) - -COMPOSE_PROJECT_NAME_TEST_UNIT ?= test_unit_$(BUILD_DIR_NAME) -DOCKER_COMPOSE_TEST_UNIT = \ - COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME_TEST_UNIT) $(DOCKER_COMPOSE) - -.PHONY: print-branch-tag-safe -print-branch-tag-safe: - @echo $(BRANCH_NAME_TAG_SAFE) - -clean: - -docker rmi $(IMAGE_CI) - -docker rmi $(IMAGE_REPO_FINAL) - -$(DOCKER_COMPOSE_TEST_UNIT) down --remove-orphans --rmi local --timeout 0 --volumes - -$(DOCKER_COMPOSE_TEST_ACCEPTANCE) down --remove-orphans --rmi local --timeout 0 --volumes - -rm -rf reports/ - -# Run the linting commands in the scope of the monorepo. -# Eslint and prettier (plus some configs) are on the root. -RUN_LINTING = ../../bin/run -w /overleaf/services/$(PROJECT_NAME) monorepo yarn run --silent -RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent - -RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/chat/reports:/overleaf/services/chat/reports --volume $(MONOREPO)/.cache:/overleaf/.cache $(IMAGE_CI) yarn run --silent -RUN_LINTING_CI_MONOREPO = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/chat/reports:/overleaf/services/chat/reports --volume $(MONOREPO)/.cache:/overleaf/.cache -w /overleaf $(IMAGE_CI) yarn run --silent - -SHELLCHECK_OPTS = \ - --shell=bash \ - --external-sources -SHELLCHECK_COLOR := $(if $(CI),--color=never,--color) -SHELLCHECK_FILES := { git ls-files "*.sh" -z; git grep -Plz "\A\#\!.*bash"; } | sort -zu - -shellcheck: - @$(SHELLCHECK_FILES) | xargs -0 -r docker run --rm -v $(HERE):/mnt -w /mnt \ - koalaman/shellcheck:stable $(SHELLCHECK_OPTS) $(SHELLCHECK_COLOR) - -shellcheck_fix: - @$(SHELLCHECK_FILES) | while IFS= read -r -d '' file; do \ - diff=$$(docker run --rm -v $(HERE):/mnt -w /mnt koalaman/shellcheck:stable $(SHELLCHECK_OPTS) --format=diff "$$file" 2>/dev/null); \ - if [ -n "$$diff" ] && ! echo "$$diff" | patch -p1 >/dev/null 2>&1; then echo "\033[31m$$file\033[0m"; \ - elif [ -n "$$diff" ]; then echo "$$file"; \ - else echo "\033[2m$$file\033[0m"; fi \ - done - -WITH_CACHE_FOLDER = \ - lint \ - lint_ci \ - lint_fix \ - format \ - format_ci \ - format_fix \ - typecheck \ - typecheck_ci \ - -$(WITH_CACHE_FOLDER): ../../.cache/prettier/ -../../.cache/prettier/: - mkdir -p $@ - -$(WITH_CACHE_FOLDER): ../../.cache/eslint/ -../../.cache/eslint/: - mkdir -p $@ - -format: - $(RUN_LINTING_MONOREPO) format services/chat - -format_ci: - $(RUN_LINTING_CI_MONOREPO) format services/chat - -format_fix: - $(RUN_LINTING_MONOREPO) format:fix services/chat - -lint: - $(RUN_LINTING) lint - -lint_ci: - -$(RUN_LINTING_CI) lint --format json --output-file reports/eslint.json - sed -i 's_"filePath":"/overleaf_"filePath":"$(MONOREPO)_g' reports/eslint.json - -lint_fix: - $(RUN_LINTING) lint:fix - -typecheck: - $(RUN_LINTING) types:check - -typecheck_ci: - $(RUN_LINTING_CI) types:check - -test: format lint typecheck shellcheck test_unit test_acceptance - -test_unit: -ifneq (,$(wildcard test/unit)) - $(DOCKER_COMPOSE_TEST_UNIT) run $(DC_RUN_FLAGS) test_unit -endif - -test_clean: test_unit_clean -test_unit_clean: -ifneq (,$(wildcard test/unit)) - $(DOCKER_COMPOSE_TEST_UNIT) down -v -t 0 -endif - -test_acceptance: test_acceptance_clean test_acceptance_pre_run test_acceptance_run - $(MAKE) test_acceptance_clean - -test_acceptance_debug: test_acceptance_clean test_acceptance_pre_run test_acceptance_run_debug - $(MAKE) test_acceptance_clean - -test_acceptance_run: -ifneq (,$(wildcard test/acceptance)) - $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run $(DC_RUN_FLAGS) test_acceptance -endif - -test_acceptance_run_debug: -ifneq (,$(wildcard test/acceptance)) - $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run -p 127.0.0.9:19999:19999 $(DC_RUN_FLAGS) test_acceptance yarn run --inspect=0.0.0.0:19999 --inspect-brk test:acceptance -endif - -test_clean: test_acceptance_clean -test_acceptance_clean: - $(DOCKER_COMPOSE_TEST_ACCEPTANCE) down -v -t 0 - -test_acceptance_pre_run: -ifneq (,$(wildcard test/acceptance/js/scripts/pre-run)) - $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run $(DC_RUN_FLAGS) test_acceptance test/acceptance/js/scripts/pre-run -endif - -benchmarks: - $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run $(DC_RUN_FLAGS) test_acceptance yarn run benchmarks - -build: - docker build \ - --pull \ - --build-arg BUILDKIT_INLINE_CACHE=1 \ - --tag $(IMAGE_CI) \ - --tag $(IMAGE_CACHE) \ - --tag $(IMAGE_REPO_FINAL) \ - --cache-from $(IMAGE_CACHE) \ - --file Dockerfile \ - ../.. - -tar: - $(DOCKER_COMPOSE) up tar - -push: - docker push $(IMAGE_REPO_FINAL) - -push_branch: - docker push $(IMAGE_CACHE) - -.PHONY: clean \ - format format_fix \ - lint lint_fix \ - build_types typecheck \ - lint_ci format_ci typecheck_ci \ - shellcheck shellcheck_fix \ - test test_clean test_unit test_unit_clean \ - test_acceptance test_acceptance_debug test_acceptance_pre_run \ - test_acceptance_run test_acceptance_run_debug test_acceptance_clean \ - benchmarks \ - build tar publish \ diff --git a/services/chat/buildscript.txt b/services/chat/buildscript.txt deleted file mode 100644 index c3cbe23489..0000000000 --- a/services/chat/buildscript.txt +++ /dev/null @@ -1,9 +0,0 @@ -chat ---dependencies=mongo ---deploy-pipeline=chat ---env-add= ---env-pass-through= ---esmock-loader=False ---node-version=24.14.1 ---pipeline-owner=44 ---public-repo=False diff --git a/services/chat/docker-compose.ci.yml b/services/chat/docker-compose.ci.yml deleted file mode 100644 index 0747d8ab9e..0000000000 --- a/services/chat/docker-compose.ci.yml +++ /dev/null @@ -1,64 +0,0 @@ -# This file was auto-generated, do not edit it directly. -# Instead run bin/update_build_scripts from -# https://github.com/overleaf/internal/ - -services: - test_unit: - image: ci/$PROJECT_NAME:$BRANCH_NAME_TAG_SAFE-$BUILD_NUMBER - user: node - volumes: - - ./reports:/overleaf/services/chat/reports - - ../../bin/shared/wait_for_it:/overleaf/bin/shared/wait_for_it - entrypoint: /overleaf/bin/shared/wait_for_it mongo:27017 --timeout=60 -- - command: yarn run test:unit:_run - environment: - CI: - MONGO_CONNECTION_STRING: mongodb://mongo/test-overleaf - NODE_ENV: test - NODE_OPTIONS: "--unhandled-rejections=strict" - depends_on: - mongo: - condition: service_started - - test_acceptance: - image: ci/$PROJECT_NAME:$BRANCH_NAME_TAG_SAFE-$BUILD_NUMBER - environment: - CI: - RETRIES: - MONGO_HOST: mongo - POSTGRES_HOST: postgres - MOCHA_GREP: ${MOCHA_GREP} - NODE_ENV: test - NODE_OPTIONS: "--unhandled-rejections=strict" - volumes: - - ./reports:/overleaf/services/chat/reports - - ../../bin/shared/wait_for_it:/overleaf/bin/shared/wait_for_it - depends_on: - mongo: - condition: service_started - user: node - entrypoint: /overleaf/bin/shared/wait_for_it mongo:27017 --timeout=60 -- - command: yarn run test:acceptance - - tar: - build: . - image: ci/$PROJECT_NAME:$BRANCH_NAME_TAG_SAFE-$BUILD_NUMBER - volumes: - - ./:/tmp/build/ - command: tar -czf /tmp/build/build.tar.gz --exclude=build.tar.gz --exclude-vcs . - user: root - - mongo: - image: mongo:8.0.11 - command: --replSet overleaf --notablescan - volumes: - - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js - - ../../bin/shared/mongodb-docker-entrypoint-wait.sh:/mongodb-docker-entrypoint-wait.sh - entrypoint: /mongodb-docker-entrypoint-wait.sh - environment: - MONGO_INITDB_DATABASE: sharelatex - extra_hosts: - # Required when using the automatic database setup for initializing the - # replica set. This override is not needed when running the setup after - # starting up mongo. - - mongo:127.0.0.1 diff --git a/services/chat/docker-compose.yml b/services/chat/docker-compose.yml deleted file mode 100644 index 32c167cd66..0000000000 --- a/services/chat/docker-compose.yml +++ /dev/null @@ -1,79 +0,0 @@ -# This file was auto-generated, do not edit it directly. -# Instead run bin/update_build_scripts from -# https://github.com/overleaf/internal/ - -services: - test_unit: - build: - context: ../.. - dockerfile: services/chat/Dockerfile - target: base - volumes: - - .:/overleaf/services/chat - - ../../node_modules:/overleaf/node_modules - - ../../libraries:/overleaf/libraries - - ../../package.json:/overleaf/package.json - - ../../.yarnrc.yml:/overleaf/.yarnrc.yml - - ../../yarn.lock:/overleaf/yarn.lock - - ../../.yarn:/overleaf/.yarn - - ../../bin/shared/wait_for_it:/overleaf/bin/shared/wait_for_it - - ../../tools/migrations:/overleaf/tools/migrations - working_dir: /overleaf/services/chat - environment: - MOCHA_GREP: ${MOCHA_GREP} - LOG_LEVEL: ${LOG_LEVEL:-} - MONGO_CONNECTION_STRING: mongodb://mongo/test-overleaf - NODE_ENV: test - NODE_OPTIONS: "--unhandled-rejections=strict" - entrypoint: /overleaf/bin/shared/wait_for_it mongo:27017 --timeout=60 -- - command: yarn run --silent test:unit - user: node - depends_on: - mongo: - condition: service_started - - test_acceptance: - build: - context: ../.. - dockerfile: services/chat/Dockerfile - target: base - volumes: - - .:/overleaf/services/chat - - ../../node_modules:/overleaf/node_modules - - ../../libraries:/overleaf/libraries - - ../../package.json:/overleaf/package.json - - ../../.yarnrc.yml:/overleaf/.yarnrc.yml - - ../../yarn.lock:/overleaf/yarn.lock - - ../../.yarn:/overleaf/.yarn - - ../../bin/shared/wait_for_it:/overleaf/bin/shared/wait_for_it - - ../../tools/migrations:/overleaf/tools/migrations - working_dir: /overleaf/services/chat - environment: - RETRIES: - MONGO_CONNECTION_STRING: mongodb://mongo/test-overleaf - POSTGRES_HOST: postgres - MOCHA_GREP: ${MOCHA_GREP} - LOG_LEVEL: ${LOG_LEVEL:-} - NODE_ENV: test - NODE_OPTIONS: "--unhandled-rejections=strict" - user: node - depends_on: - mongo: - condition: service_started - entrypoint: /overleaf/bin/shared/wait_for_it mongo:27017 --timeout=60 -- - command: yarn run --silent test:acceptance - - mongo: - image: mongo:8.0.11 - command: --replSet overleaf --notablescan - volumes: - - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js - - ../../bin/shared/mongodb-docker-entrypoint-wait.sh:/mongodb-docker-entrypoint-wait.sh - entrypoint: /mongodb-docker-entrypoint-wait.sh - environment: - MONGO_INITDB_DATABASE: sharelatex - extra_hosts: - # Required when using the automatic database setup for initializing the - # replica set. This override is not needed when running the setup after - # starting up mongo. - - mongo:127.0.0.1 diff --git a/services/clsi/.nvmrc b/services/clsi/.nvmrc deleted file mode 100644 index 8e35034890..0000000000 --- a/services/clsi/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -24.14.1 diff --git a/services/clsi/.nycrc b/services/clsi/.nycrc deleted file mode 100644 index e1849f18cc..0000000000 --- a/services/clsi/.nycrc +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extension": [".js", ".cjs", ".ts"], - "cwd": "../../", - "produce-source-map": true, - "source-map": true, - "temp-dir": "/tmp/.nyc_output" -} diff --git a/services/clsi/Dockerfile b/services/clsi/Dockerfile deleted file mode 100644 index 603d6ddf7a..0000000000 --- a/services/clsi/Dockerfile +++ /dev/null @@ -1,68 +0,0 @@ -# This file was auto-generated, do not edit it directly. -# Instead run bin/update_build_scripts from -# https://github.com/overleaf/internal/ - -FROM node:24.14.1 AS base - -# Corepack setup, shared between all the images. -ENV PATH="/overleaf/node_modules/.bin:$PATH" -ENV COREPACK_HOME=/opt/corepack -RUN corepack enable && corepack install -g yarn@4.14.1 -ENV COREPACK_ENABLE_NETWORK=0 - -WORKDIR /overleaf/services/clsi -COPY services/clsi/install_deps.sh /overleaf/services/clsi/ -RUN chmod 0755 ./install_deps.sh && ./install_deps.sh -ENTRYPOINT ["/bin/sh", "/entrypoint.sh"] -COPY services/clsi/entrypoint.sh / - -# Google Cloud Storage needs a writable $HOME/.config for resumable uploads -# (see https://googleapis.dev/nodejs/storage/latest/File.html#createWriteStream) -RUN mkdir /home/node/.config && chown node:node /home/node/.config - -FROM base AS app - -COPY package.json yarn.lock .yarnrc.yml /overleaf/ -COPY libraries/eslint-plugin/package.json /overleaf/libraries/eslint-plugin/package.json -COPY libraries/fetch-utils/package.json /overleaf/libraries/fetch-utils/package.json -COPY libraries/logger/package.json /overleaf/libraries/logger/package.json -COPY libraries/metrics/package.json /overleaf/libraries/metrics/package.json -COPY libraries/o-error/package.json /overleaf/libraries/o-error/package.json -COPY libraries/overleaf-editor-core/package.json /overleaf/libraries/overleaf-editor-core/package.json -COPY libraries/promise-utils/package.json /overleaf/libraries/promise-utils/package.json -COPY libraries/settings/package.json /overleaf/libraries/settings/package.json -COPY libraries/stream-utils/package.json /overleaf/libraries/stream-utils/package.json -COPY libraries/validation-tools/package.json /overleaf/libraries/validation-tools/package.json -COPY services/clsi/package.json /overleaf/services/clsi/package.json -COPY .yarn/patches/ /overleaf/.yarn/patches/ - -RUN cd /overleaf && yarn workspaces focus @overleaf/clsi overleaf -COPY libraries/eslint-plugin/ /overleaf/libraries/eslint-plugin/ -COPY libraries/fetch-utils/ /overleaf/libraries/fetch-utils/ -COPY libraries/logger/ /overleaf/libraries/logger/ -COPY libraries/metrics/ /overleaf/libraries/metrics/ -COPY libraries/o-error/ /overleaf/libraries/o-error/ -COPY libraries/overleaf-editor-core/ /overleaf/libraries/overleaf-editor-core/ -COPY libraries/promise-utils/ /overleaf/libraries/promise-utils/ -COPY libraries/settings/ /overleaf/libraries/settings/ -COPY libraries/stream-utils/ /overleaf/libraries/stream-utils/ -COPY libraries/validation-tools/ /overleaf/libraries/validation-tools/ -COPY services/clsi/ /overleaf/services/clsi/ - -FROM app AS with-texlive - -RUN apt-get update \ - && apt-cache depends texlive-full | grep "Depends: " | grep -v -- "-doc" | grep -v -- "-lang-" | sed 's/Depends: //' | xargs apt-get install -y --no-install-recommends \ - && apt-get install -y --no-install-recommends fontconfig inkscape python3-pygments qpdf \ - && rm -rf /var/lib/apt/lists/* - -RUN mkdir -p cache compiles output \ -&& chown node:node cache compiles output - -CMD ["node", "--expose-gc", "app.js"] - -FROM app -RUN mkdir -p cache compiles output \ -&& chown node:node cache compiles output - -CMD ["node", "--expose-gc", "app.js"] diff --git a/services/clsi/Makefile b/services/clsi/Makefile deleted file mode 100644 index 47eae7ee8a..0000000000 --- a/services/clsi/Makefile +++ /dev/null @@ -1,210 +0,0 @@ -# This file was auto-generated, do not edit it directly. -# Instead run bin/update_build_scripts from -# https://github.com/overleaf/internal/ - -export BUILD_NUMBER ?= local -export BRANCH_NAME ?= $(shell git rev-parse --abbrev-ref HEAD) -export BRANCH_NAME_TAG_SAFE = $(shell echo $(BRANCH_NAME) | sed 's/\//\-\-/g' | cut -c1-96) -export PROJECT_NAME = clsi -BUILD_DIR_NAME = $(shell pwd | xargs basename | tr -cd '[a-zA-Z0-9_.\-]') -HERE=$(shell pwd) -export MONOREPO ?= $(shell cd ../../ && pwd) -export RETRIES ?= $(if $(CI),3,0) -IMAGE_CI ?= ci/$(PROJECT_NAME):$(BRANCH_NAME_TAG_SAFE)-$(BUILD_NUMBER) -IMAGE_REPO ?= us-east1-docker.pkg.dev/overleaf-ops/ol-docker/$(PROJECT_NAME) -IMAGE_REPO_FINAL ?= $(IMAGE_REPO):$(BRANCH_NAME_TAG_SAFE)-$(BUILD_NUMBER) -IMAGE_CACHE ?= $(IMAGE_REPO):cache-$(shell cat \ - $(MONOREPO)/package.json \ - $(MONOREPO)/yarn.lock \ - $(MONOREPO)/libraries/eslint-plugin/package.json \ - $(MONOREPO)/libraries/fetch-utils/package.json \ - $(MONOREPO)/libraries/logger/package.json \ - $(MONOREPO)/libraries/metrics/package.json \ - $(MONOREPO)/libraries/o-error/package.json \ - $(MONOREPO)/libraries/overleaf-editor-core/package.json \ - $(MONOREPO)/libraries/promise-utils/package.json \ - $(MONOREPO)/libraries/settings/package.json \ - $(MONOREPO)/libraries/stream-utils/package.json \ - $(MONOREPO)/libraries/validation-tools/package.json \ - $(MONOREPO)/services/clsi/package.json \ - $(MONOREPO)/.yarn/patches/* \ -| sha256sum | cut -d '-' -f1) -export MOCHA_GREP ?= - -DOCKER_COMPOSE_FLAGS ?= -f docker-compose.yml -DOCKER_COMPOSE := docker compose ${DOCKER_COMPOSE_FLAGS} - -# Common flags for docker compose run. -# --rm Tear down test runner container -# --build Make sure that we use the latest base image -# --quiet-build Hide build output -ifeq ($(CI),true) - DC_RUN_FLAGS := --rm -else - DC_RUN_FLAGS := --rm --build --quiet-build -endif - -COMPOSE_PROJECT_NAME_TEST_ACCEPTANCE ?= test_acceptance_$(BUILD_DIR_NAME) -DOCKER_COMPOSE_TEST_ACCEPTANCE = \ - COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME_TEST_ACCEPTANCE) $(DOCKER_COMPOSE) - -COMPOSE_PROJECT_NAME_TEST_UNIT ?= test_unit_$(BUILD_DIR_NAME) -DOCKER_COMPOSE_TEST_UNIT = \ - COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME_TEST_UNIT) $(DOCKER_COMPOSE) - -.PHONY: print-branch-tag-safe -print-branch-tag-safe: - @echo $(BRANCH_NAME_TAG_SAFE) - -clean: - -docker rmi $(IMAGE_CI) - -docker rmi $(IMAGE_REPO_FINAL) - -$(DOCKER_COMPOSE_TEST_UNIT) down --remove-orphans --rmi local --timeout 0 --volumes - -$(DOCKER_COMPOSE_TEST_ACCEPTANCE) down --remove-orphans --rmi local --timeout 0 --volumes - -rm -rf reports/ - -git clean -dfX cache compiles output - -# Run the linting commands in the scope of the monorepo. -# Eslint and prettier (plus some configs) are on the root. -RUN_LINTING = ../../bin/run -w /overleaf/services/$(PROJECT_NAME) monorepo yarn run --silent -RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent - -RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/clsi/reports:/overleaf/services/clsi/reports --volume $(MONOREPO)/.cache:/overleaf/.cache $(IMAGE_CI) yarn run --silent -RUN_LINTING_CI_MONOREPO = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/clsi/reports:/overleaf/services/clsi/reports --volume $(MONOREPO)/.cache:/overleaf/.cache -w /overleaf $(IMAGE_CI) yarn run --silent - -SHELLCHECK_OPTS = \ - --shell=bash \ - --external-sources -SHELLCHECK_COLOR := $(if $(CI),--color=never,--color) -SHELLCHECK_FILES := { git ls-files "*.sh" -z; git grep -Plz "\A\#\!.*bash"; } | sort -zu - -shellcheck: - @$(SHELLCHECK_FILES) | xargs -0 -r docker run --rm -v $(HERE):/mnt -w /mnt \ - koalaman/shellcheck:stable $(SHELLCHECK_OPTS) $(SHELLCHECK_COLOR) - -shellcheck_fix: - @$(SHELLCHECK_FILES) | while IFS= read -r -d '' file; do \ - diff=$$(docker run --rm -v $(HERE):/mnt -w /mnt koalaman/shellcheck:stable $(SHELLCHECK_OPTS) --format=diff "$$file" 2>/dev/null); \ - if [ -n "$$diff" ] && ! echo "$$diff" | patch -p1 >/dev/null 2>&1; then echo "\033[31m$$file\033[0m"; \ - elif [ -n "$$diff" ]; then echo "$$file"; \ - else echo "\033[2m$$file\033[0m"; fi \ - done - -WITH_CACHE_FOLDER = \ - lint \ - lint_ci \ - lint_fix \ - format \ - format_ci \ - format_fix \ - typecheck \ - typecheck_ci \ - -$(WITH_CACHE_FOLDER): ../../.cache/prettier/ -../../.cache/prettier/: - mkdir -p $@ - -$(WITH_CACHE_FOLDER): ../../.cache/eslint/ -../../.cache/eslint/: - mkdir -p $@ - -format: - $(RUN_LINTING_MONOREPO) format services/clsi - -format_ci: - $(RUN_LINTING_CI_MONOREPO) format services/clsi - -format_fix: - $(RUN_LINTING_MONOREPO) format:fix services/clsi - -lint: - $(RUN_LINTING) lint - -lint_ci: - -$(RUN_LINTING_CI) lint --format json --output-file reports/eslint.json - sed -i 's_"filePath":"/overleaf_"filePath":"$(MONOREPO)_g' reports/eslint.json - -lint_fix: - $(RUN_LINTING) lint:fix - -typecheck: - $(RUN_LINTING) types:check - -typecheck_ci: - $(RUN_LINTING_CI) types:check - -test: format lint typecheck shellcheck test_unit test_acceptance - -test_unit: -ifneq (,$(wildcard test/unit)) - $(DOCKER_COMPOSE_TEST_UNIT) run $(DC_RUN_FLAGS) test_unit -endif - -test_clean: test_unit_clean -test_unit_clean: -ifneq (,$(wildcard test/unit)) - $(DOCKER_COMPOSE_TEST_UNIT) down -v -t 0 -endif - -test_acceptance: test_acceptance_clean test_acceptance_pre_run test_acceptance_run - $(MAKE) test_acceptance_clean - -test_acceptance_debug: test_acceptance_clean test_acceptance_pre_run test_acceptance_run_debug - $(MAKE) test_acceptance_clean - -test_acceptance_run: -ifneq (,$(wildcard test/acceptance)) - $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run $(DC_RUN_FLAGS) test_acceptance -endif - -test_acceptance_run_debug: -ifneq (,$(wildcard test/acceptance)) - $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run -p 127.0.0.9:19999:19999 $(DC_RUN_FLAGS) test_acceptance yarn run --inspect=0.0.0.0:19999 --inspect-brk test:acceptance -endif - -test_clean: test_acceptance_clean -test_acceptance_clean: - $(DOCKER_COMPOSE_TEST_ACCEPTANCE) down -v -t 0 - -test_acceptance_pre_run: - -docker pull us-east1-docker.pkg.dev/overleaf-ops/ol-docker/pandoc:3.9 - -docker pull us-east1-docker.pkg.dev/overleaf-ops/ol-docker/pandoc-staging:3.9 - -cd ../../ && docker build -t us-east1-docker.pkg.dev/overleaf-ops/ol-docker/pdftocairo:24.02 dockerfiles/pdftocairo -ifneq (,$(wildcard test/acceptance/js/scripts/pre-run)) - $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run $(DC_RUN_FLAGS) test_acceptance test/acceptance/js/scripts/pre-run -endif - -benchmarks: - $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run $(DC_RUN_FLAGS) test_acceptance yarn run benchmarks - -build: - docker build \ - --pull \ - --build-arg BUILDKIT_INLINE_CACHE=1 \ - --tag $(IMAGE_CI) \ - --tag $(IMAGE_CACHE) \ - --tag $(IMAGE_REPO_FINAL) \ - --cache-from $(IMAGE_CACHE) \ - --file Dockerfile \ - ../.. - -tar: - $(DOCKER_COMPOSE) up tar - -push: - docker push $(IMAGE_REPO_FINAL) - -push_branch: - docker push $(IMAGE_CACHE) - -.PHONY: clean \ - format format_fix \ - lint lint_fix \ - build_types typecheck \ - lint_ci format_ci typecheck_ci \ - shellcheck shellcheck_fix \ - test test_clean test_unit test_unit_clean \ - test_acceptance test_acceptance_debug test_acceptance_pre_run \ - test_acceptance_run test_acceptance_run_debug test_acceptance_clean \ - benchmarks \ - build tar publish \ diff --git a/services/clsi/buildscript.txt b/services/clsi/buildscript.txt deleted file mode 100644 index 690b547f74..0000000000 --- a/services/clsi/buildscript.txt +++ /dev/null @@ -1,12 +0,0 @@ -clsi ---data-dirs=cache,compiles,output ---dependencies= ---env-add=DOWNLOAD_HOST=http://clsi-nginx:8080,ALLOWED_COMPILE_GROUPS=clsi-perf simple-latex-file,ENABLE_PDF_CACHING=true,PDF_CACHING_ENABLE_WORKER_POOL=true,ALLOWED_IMAGES=quay.io/sharelatex/texlive-full:2017.1 quay.io/sharelatex/texlive-full:2025.1 quay.io/sharelatex/pandoc:3.9 quay.io/sharelatex/pdftocairo:24.02,TEXLIVE_IMAGE=quay.io/sharelatex/texlive-full:2025.1,TEX_LIVE_IMAGE_NAME_OVERRIDE=us-east1-docker.pkg.dev/overleaf-ops/ol-docker,TEXLIVE_IMAGE_USER=tex,SANDBOXED_COMPILES=true,SANDBOXED_COMPILES_HOST_DIR_COMPILES=$PWD/compiles,SANDBOXED_COMPILES_HOST_DIR_OUTPUT=$PWD/output,ENABLE_PANDOC_CONVERSIONS=true,ENABLE_PDF_CONVERSIONS=true ---env-pass-through= ---esmock-loader=False ---node-version=24.14.1 ---pipeline-owner=32 ---public-repo=True ---test-unit-vitest=True ---tsconfig-extra-includes=vitest.config.unit.cjs ---use-large-ci-runner=True diff --git a/services/clsi/docker-compose.ci.yml b/services/clsi/docker-compose.ci.yml deleted file mode 100644 index 3017b9eb78..0000000000 --- a/services/clsi/docker-compose.ci.yml +++ /dev/null @@ -1,70 +0,0 @@ -# This file was auto-generated, do not edit it directly. -# Instead run bin/update_build_scripts from -# https://github.com/overleaf/internal/ - -services: - test_unit: - image: ci/$PROJECT_NAME:$BRANCH_NAME_TAG_SAFE-$BUILD_NUMBER - volumes: - - ./reports:/overleaf/services/clsi/reports - - ../../tsconfig.backend.json:/overleaf/tsconfig.backend.json - command: yarn run test:unit:_run - environment: - CI: - MONGO_CONNECTION_STRING: mongodb://mongo/test-overleaf - NODE_ENV: test - NODE_OPTIONS: "--unhandled-rejections=strict" - VITEST_NO_CACHE: true - - test_acceptance: - image: ci/$PROJECT_NAME:$BRANCH_NAME_TAG_SAFE-$BUILD_NUMBER - environment: - CI: - RETRIES: - MONGO_HOST: mongo - POSTGRES_HOST: postgres - MOCHA_GREP: ${MOCHA_GREP} - NODE_ENV: test - NODE_OPTIONS: "--unhandled-rejections=strict" - DOWNLOAD_HOST: http://clsi-nginx:8080 - ALLOWED_COMPILE_GROUPS: clsi-perf simple-latex-file - ENABLE_PDF_CACHING: true - PDF_CACHING_ENABLE_WORKER_POOL: true - ALLOWED_IMAGES: quay.io/sharelatex/texlive-full:2017.1 quay.io/sharelatex/texlive-full:2025.1 quay.io/sharelatex/pandoc:3.9 quay.io/sharelatex/pdftocairo:24.02 - TEXLIVE_IMAGE: quay.io/sharelatex/texlive-full:2025.1 - TEX_LIVE_IMAGE_NAME_OVERRIDE: us-east1-docker.pkg.dev/overleaf-ops/ol-docker - TEXLIVE_IMAGE_USER: tex - SANDBOXED_COMPILES: true - SANDBOXED_COMPILES_HOST_DIR_COMPILES: $PWD/compiles - SANDBOXED_COMPILES_HOST_DIR_OUTPUT: $PWD/output - ENABLE_PANDOC_CONVERSIONS: true - ENABLE_PDF_CONVERSIONS: true - volumes: - - ./reports:/overleaf/services/clsi/reports - - ./compiles:/overleaf/services/clsi/compiles - - ./output:/overleaf/services/clsi/output - - /var/run/docker.sock:/var/run/docker.sock - depends_on: - clsi-nginx: - condition: service_started - command: yarn run test:acceptance - - tar: - build: . - image: ci/$PROJECT_NAME:$BRANCH_NAME_TAG_SAFE-$BUILD_NUMBER - volumes: - - ./:/tmp/build/ - command: tar -czf /tmp/build/build.tar.gz --exclude=build.tar.gz --exclude-vcs . - user: root - - clsi-nginx: - image: nginx:1.28 - read_only: true - tmpfs: - - /tmp - - /var/cache/nginx - - /run - volumes: - - ./output:/output:ro - - ./nginx.conf:/etc/nginx/conf.d/nginx.conf:ro - - ./tiny.pdf:/var/clsi/tiny.pdf:ro diff --git a/services/clsi/docker-compose.yml b/services/clsi/docker-compose.yml deleted file mode 100644 index 1ea4549b20..0000000000 --- a/services/clsi/docker-compose.yml +++ /dev/null @@ -1,80 +0,0 @@ -# This file was auto-generated, do not edit it directly. -# Instead run bin/update_build_scripts from -# https://github.com/overleaf/internal/ - -services: - test_unit: - build: - context: ../.. - dockerfile: services/clsi/Dockerfile - target: base - volumes: - - .:/overleaf/services/clsi - - ../../node_modules:/overleaf/node_modules - - ../../libraries:/overleaf/libraries - - ../../package.json:/overleaf/package.json - - ../../.yarnrc.yml:/overleaf/.yarnrc.yml - - ../../yarn.lock:/overleaf/yarn.lock - - ../../.yarn:/overleaf/.yarn - - ../../tsconfig.backend.json:/overleaf/tsconfig.backend.json - working_dir: /overleaf/services/clsi - environment: - MOCHA_GREP: ${MOCHA_GREP} - LOG_LEVEL: ${LOG_LEVEL:-} - MONGO_CONNECTION_STRING: mongodb://mongo/test-overleaf - NODE_ENV: test - NODE_OPTIONS: "--unhandled-rejections=strict" - command: yarn run --silent test:unit - - test_acceptance: - build: - context: ../.. - dockerfile: services/clsi/Dockerfile - target: base - volumes: - - .:/overleaf/services/clsi - - ../../node_modules:/overleaf/node_modules - - ../../libraries:/overleaf/libraries - - ../../package.json:/overleaf/package.json - - ../../.yarnrc.yml:/overleaf/.yarnrc.yml - - ../../yarn.lock:/overleaf/yarn.lock - - ../../.yarn:/overleaf/.yarn - - /var/run/docker.sock:/var/run/docker.sock - working_dir: /overleaf/services/clsi - environment: - RETRIES: - MONGO_CONNECTION_STRING: mongodb://mongo/test-overleaf - POSTGRES_HOST: postgres - MOCHA_GREP: ${MOCHA_GREP} - LOG_LEVEL: ${LOG_LEVEL:-} - NODE_ENV: test - NODE_OPTIONS: "--unhandled-rejections=strict" - DOWNLOAD_HOST: http://clsi-nginx:8080 - ALLOWED_COMPILE_GROUPS: clsi-perf simple-latex-file - ENABLE_PDF_CACHING: true - PDF_CACHING_ENABLE_WORKER_POOL: true - ALLOWED_IMAGES: quay.io/sharelatex/texlive-full:2017.1 quay.io/sharelatex/texlive-full:2025.1 quay.io/sharelatex/pandoc:3.9 quay.io/sharelatex/pdftocairo:24.02 - TEXLIVE_IMAGE: quay.io/sharelatex/texlive-full:2025.1 - TEX_LIVE_IMAGE_NAME_OVERRIDE: us-east1-docker.pkg.dev/overleaf-ops/ol-docker - TEXLIVE_IMAGE_USER: tex - SANDBOXED_COMPILES: true - SANDBOXED_COMPILES_HOST_DIR_COMPILES: $PWD/compiles - SANDBOXED_COMPILES_HOST_DIR_OUTPUT: $PWD/output - ENABLE_PANDOC_CONVERSIONS: true - ENABLE_PDF_CONVERSIONS: true - depends_on: - clsi-nginx: - condition: service_started - command: yarn run --silent test:acceptance - - clsi-nginx: - image: nginx:1.28 - read_only: true - tmpfs: - - /tmp - - /var/cache/nginx - - /run - volumes: - - ./output:/output:ro - - ./nginx.conf:/etc/nginx/conf.d/nginx.conf:ro - - ./tiny.pdf:/var/clsi/tiny.pdf:ro diff --git a/services/docstore/.nvmrc b/services/docstore/.nvmrc deleted file mode 100644 index 8e35034890..0000000000 --- a/services/docstore/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -24.14.1 diff --git a/services/docstore/Dockerfile b/services/docstore/Dockerfile deleted file mode 100644 index ae69304db4..0000000000 --- a/services/docstore/Dockerfile +++ /dev/null @@ -1,56 +0,0 @@ -# This file was auto-generated, do not edit it directly. -# Instead run bin/update_build_scripts from -# https://github.com/overleaf/internal/ - -FROM node:24.14.1 AS base - -# Corepack setup, shared between all the images. -ENV PATH="/overleaf/node_modules/.bin:$PATH" -ENV COREPACK_HOME=/opt/corepack -RUN corepack enable && corepack install -g yarn@4.14.1 -ENV COREPACK_ENABLE_NETWORK=0 - -WORKDIR /overleaf/services/docstore - -# Google Cloud Storage needs a writable $HOME/.config for resumable uploads -# (see https://googleapis.dev/nodejs/storage/latest/File.html#createWriteStream) -RUN mkdir /home/node/.config && chown node:node /home/node/.config - -FROM base AS app - -COPY package.json yarn.lock .yarnrc.yml /overleaf/ -COPY libraries/eslint-plugin/package.json /overleaf/libraries/eslint-plugin/package.json -COPY libraries/fetch-utils/package.json /overleaf/libraries/fetch-utils/package.json -COPY libraries/logger/package.json /overleaf/libraries/logger/package.json -COPY libraries/metrics/package.json /overleaf/libraries/metrics/package.json -COPY libraries/mongo-utils/package.json /overleaf/libraries/mongo-utils/package.json -COPY libraries/o-error/package.json /overleaf/libraries/o-error/package.json -COPY libraries/object-persistor/package.json /overleaf/libraries/object-persistor/package.json -COPY libraries/promise-utils/package.json /overleaf/libraries/promise-utils/package.json -COPY libraries/settings/package.json /overleaf/libraries/settings/package.json -COPY libraries/stream-utils/package.json /overleaf/libraries/stream-utils/package.json -COPY libraries/validation-tools/package.json /overleaf/libraries/validation-tools/package.json -COPY services/docstore/package.json /overleaf/services/docstore/package.json -COPY tools/migrations/package.json /overleaf/tools/migrations/package.json -COPY .yarn/patches/ /overleaf/.yarn/patches/ -COPY tools/migrations/ /overleaf/tools/migrations/ - -RUN cd /overleaf && yarn workspaces focus @overleaf/docstore overleaf -COPY libraries/eslint-plugin/ /overleaf/libraries/eslint-plugin/ -COPY libraries/fetch-utils/ /overleaf/libraries/fetch-utils/ -COPY libraries/logger/ /overleaf/libraries/logger/ -COPY libraries/metrics/ /overleaf/libraries/metrics/ -COPY libraries/mongo-utils/ /overleaf/libraries/mongo-utils/ -COPY libraries/o-error/ /overleaf/libraries/o-error/ -COPY libraries/object-persistor/ /overleaf/libraries/object-persistor/ -COPY libraries/promise-utils/ /overleaf/libraries/promise-utils/ -COPY libraries/settings/ /overleaf/libraries/settings/ -COPY libraries/stream-utils/ /overleaf/libraries/stream-utils/ -COPY libraries/validation-tools/ /overleaf/libraries/validation-tools/ -COPY services/docstore/ /overleaf/services/docstore/ -COPY tools/migrations/ /overleaf/tools/migrations/ - -FROM app -USER node - -CMD ["node", "--expose-gc", "app.js"] diff --git a/services/docstore/Makefile b/services/docstore/Makefile deleted file mode 100644 index 6cdcd7d322..0000000000 --- a/services/docstore/Makefile +++ /dev/null @@ -1,208 +0,0 @@ -# This file was auto-generated, do not edit it directly. -# Instead run bin/update_build_scripts from -# https://github.com/overleaf/internal/ - -export BUILD_NUMBER ?= local -export BRANCH_NAME ?= $(shell git rev-parse --abbrev-ref HEAD) -export BRANCH_NAME_TAG_SAFE = $(shell echo $(BRANCH_NAME) | sed 's/\//\-\-/g' | cut -c1-96) -export PROJECT_NAME = docstore -BUILD_DIR_NAME = $(shell pwd | xargs basename | tr -cd '[a-zA-Z0-9_.\-]') -HERE=$(shell pwd) -export MONOREPO ?= $(shell cd ../../ && pwd) -export RETRIES ?= $(if $(CI),3,0) -IMAGE_CI ?= ci/$(PROJECT_NAME):$(BRANCH_NAME_TAG_SAFE)-$(BUILD_NUMBER) -IMAGE_REPO ?= us-east1-docker.pkg.dev/overleaf-ops/ol-docker/$(PROJECT_NAME) -IMAGE_REPO_FINAL ?= $(IMAGE_REPO):$(BRANCH_NAME_TAG_SAFE)-$(BUILD_NUMBER) -IMAGE_CACHE ?= $(IMAGE_REPO):cache-$(shell cat \ - $(MONOREPO)/package.json \ - $(MONOREPO)/yarn.lock \ - $(MONOREPO)/libraries/eslint-plugin/package.json \ - $(MONOREPO)/libraries/fetch-utils/package.json \ - $(MONOREPO)/libraries/logger/package.json \ - $(MONOREPO)/libraries/metrics/package.json \ - $(MONOREPO)/libraries/mongo-utils/package.json \ - $(MONOREPO)/libraries/o-error/package.json \ - $(MONOREPO)/libraries/object-persistor/package.json \ - $(MONOREPO)/libraries/promise-utils/package.json \ - $(MONOREPO)/libraries/settings/package.json \ - $(MONOREPO)/libraries/stream-utils/package.json \ - $(MONOREPO)/libraries/validation-tools/package.json \ - $(MONOREPO)/services/docstore/package.json \ - $(MONOREPO)/tools/migrations/package.json \ - $(MONOREPO)/.yarn/patches/* \ -| sha256sum | cut -d '-' -f1) -export MOCHA_GREP ?= - -DOCKER_COMPOSE_FLAGS ?= -f docker-compose.yml -DOCKER_COMPOSE := docker compose ${DOCKER_COMPOSE_FLAGS} - -# Common flags for docker compose run. -# --rm Tear down test runner container -# --build Make sure that we use the latest base image -# --quiet-build Hide build output -ifeq ($(CI),true) - DC_RUN_FLAGS := --rm -else - DC_RUN_FLAGS := --rm --build --quiet-build -endif - -COMPOSE_PROJECT_NAME_TEST_ACCEPTANCE ?= test_acceptance_$(BUILD_DIR_NAME) -DOCKER_COMPOSE_TEST_ACCEPTANCE = \ - COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME_TEST_ACCEPTANCE) $(DOCKER_COMPOSE) - -COMPOSE_PROJECT_NAME_TEST_UNIT ?= test_unit_$(BUILD_DIR_NAME) -DOCKER_COMPOSE_TEST_UNIT = \ - COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME_TEST_UNIT) $(DOCKER_COMPOSE) - -.PHONY: print-branch-tag-safe -print-branch-tag-safe: - @echo $(BRANCH_NAME_TAG_SAFE) - -clean: - -docker rmi $(IMAGE_CI) - -docker rmi $(IMAGE_REPO_FINAL) - -$(DOCKER_COMPOSE_TEST_UNIT) down --remove-orphans --rmi local --timeout 0 --volumes - -$(DOCKER_COMPOSE_TEST_ACCEPTANCE) down --remove-orphans --rmi local --timeout 0 --volumes - -rm -rf reports/ - -# Run the linting commands in the scope of the monorepo. -# Eslint and prettier (plus some configs) are on the root. -RUN_LINTING = ../../bin/run -w /overleaf/services/$(PROJECT_NAME) monorepo yarn run --silent -RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent - -RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/docstore/reports:/overleaf/services/docstore/reports --volume $(MONOREPO)/.cache:/overleaf/.cache $(IMAGE_CI) yarn run --silent -RUN_LINTING_CI_MONOREPO = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/docstore/reports:/overleaf/services/docstore/reports --volume $(MONOREPO)/.cache:/overleaf/.cache -w /overleaf $(IMAGE_CI) yarn run --silent - -SHELLCHECK_OPTS = \ - --shell=bash \ - --external-sources -SHELLCHECK_COLOR := $(if $(CI),--color=never,--color) -SHELLCHECK_FILES := { git ls-files "*.sh" -z; git grep -Plz "\A\#\!.*bash"; } | sort -zu - -shellcheck: - @$(SHELLCHECK_FILES) | xargs -0 -r docker run --rm -v $(HERE):/mnt -w /mnt \ - koalaman/shellcheck:stable $(SHELLCHECK_OPTS) $(SHELLCHECK_COLOR) - -shellcheck_fix: - @$(SHELLCHECK_FILES) | while IFS= read -r -d '' file; do \ - diff=$$(docker run --rm -v $(HERE):/mnt -w /mnt koalaman/shellcheck:stable $(SHELLCHECK_OPTS) --format=diff "$$file" 2>/dev/null); \ - if [ -n "$$diff" ] && ! echo "$$diff" | patch -p1 >/dev/null 2>&1; then echo "\033[31m$$file\033[0m"; \ - elif [ -n "$$diff" ]; then echo "$$file"; \ - else echo "\033[2m$$file\033[0m"; fi \ - done - -WITH_CACHE_FOLDER = \ - lint \ - lint_ci \ - lint_fix \ - format \ - format_ci \ - format_fix \ - typecheck \ - typecheck_ci \ - -$(WITH_CACHE_FOLDER): ../../.cache/prettier/ -../../.cache/prettier/: - mkdir -p $@ - -$(WITH_CACHE_FOLDER): ../../.cache/eslint/ -../../.cache/eslint/: - mkdir -p $@ - -format: - $(RUN_LINTING_MONOREPO) format services/docstore - -format_ci: - $(RUN_LINTING_CI_MONOREPO) format services/docstore - -format_fix: - $(RUN_LINTING_MONOREPO) format:fix services/docstore - -lint: - $(RUN_LINTING) lint - -lint_ci: - -$(RUN_LINTING_CI) lint --format json --output-file reports/eslint.json - sed -i 's_"filePath":"/overleaf_"filePath":"$(MONOREPO)_g' reports/eslint.json - -lint_fix: - $(RUN_LINTING) lint:fix - -typecheck: - $(RUN_LINTING) types:check - -typecheck_ci: - $(RUN_LINTING_CI) types:check - -test: format lint typecheck shellcheck test_unit test_acceptance - -test_unit: -ifneq (,$(wildcard test/unit)) - $(DOCKER_COMPOSE_TEST_UNIT) run $(DC_RUN_FLAGS) test_unit -endif - -test_clean: test_unit_clean -test_unit_clean: -ifneq (,$(wildcard test/unit)) - $(DOCKER_COMPOSE_TEST_UNIT) down -v -t 0 -endif - -test_acceptance: test_acceptance_clean test_acceptance_pre_run test_acceptance_run - $(MAKE) test_acceptance_clean - -test_acceptance_debug: test_acceptance_clean test_acceptance_pre_run test_acceptance_run_debug - $(MAKE) test_acceptance_clean - -test_acceptance_run: -ifneq (,$(wildcard test/acceptance)) - $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run $(DC_RUN_FLAGS) test_acceptance -endif - -test_acceptance_run_debug: -ifneq (,$(wildcard test/acceptance)) - $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run -p 127.0.0.9:19999:19999 $(DC_RUN_FLAGS) test_acceptance yarn run --inspect=0.0.0.0:19999 --inspect-brk test:acceptance -endif - -test_clean: test_acceptance_clean -test_acceptance_clean: - $(DOCKER_COMPOSE_TEST_ACCEPTANCE) down -v -t 0 - -test_acceptance_pre_run: -ifneq (,$(wildcard test/acceptance/js/scripts/pre-run)) - $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run $(DC_RUN_FLAGS) test_acceptance test/acceptance/js/scripts/pre-run -endif - -benchmarks: - $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run $(DC_RUN_FLAGS) test_acceptance yarn run benchmarks - -build: - docker build \ - --pull \ - --build-arg BUILDKIT_INLINE_CACHE=1 \ - --tag $(IMAGE_CI) \ - --tag $(IMAGE_CACHE) \ - --tag $(IMAGE_REPO_FINAL) \ - --cache-from $(IMAGE_CACHE) \ - --file Dockerfile \ - ../.. - -tar: - $(DOCKER_COMPOSE) up tar - -push: - docker push $(IMAGE_REPO_FINAL) - -push_branch: - docker push $(IMAGE_CACHE) - -.PHONY: clean \ - format format_fix \ - lint lint_fix \ - build_types typecheck \ - lint_ci format_ci typecheck_ci \ - shellcheck shellcheck_fix \ - test test_clean test_unit test_unit_clean \ - test_acceptance test_acceptance_debug test_acceptance_pre_run \ - test_acceptance_run test_acceptance_run_debug test_acceptance_clean \ - benchmarks \ - build tar publish \ diff --git a/services/docstore/buildscript.txt b/services/docstore/buildscript.txt deleted file mode 100644 index c0be66d047..0000000000 --- a/services/docstore/buildscript.txt +++ /dev/null @@ -1,11 +0,0 @@ -docstore ---dependencies=mongo,gcs ---deploy-pipeline=docstore ---env-add= ---env-pass-through= ---esmock-loader=False ---node-version=24.14.1 ---pipeline-owner=32 ---public-repo=True ---test-unit-vitest=True ---tsconfig-extra-includes=vitest.config.unit.cjs diff --git a/services/docstore/docker-compose.ci.yml b/services/docstore/docker-compose.ci.yml deleted file mode 100644 index 6ba8256375..0000000000 --- a/services/docstore/docker-compose.ci.yml +++ /dev/null @@ -1,79 +0,0 @@ -# This file was auto-generated, do not edit it directly. -# Instead run bin/update_build_scripts from -# https://github.com/overleaf/internal/ - -services: - test_unit: - image: ci/$PROJECT_NAME:$BRANCH_NAME_TAG_SAFE-$BUILD_NUMBER - user: node - volumes: - - ./reports:/overleaf/services/docstore/reports - - ../../bin/shared/wait_for_it:/overleaf/bin/shared/wait_for_it - - ../../tsconfig.backend.json:/overleaf/tsconfig.backend.json - entrypoint: /overleaf/bin/shared/wait_for_it mongo:27017 --timeout=60 -- - command: yarn run test:unit:_run - environment: - CI: - MONGO_CONNECTION_STRING: mongodb://mongo/test-overleaf - NODE_ENV: test - NODE_OPTIONS: "--unhandled-rejections=strict" - VITEST_NO_CACHE: true - depends_on: - mongo: - condition: service_started - - test_acceptance: - image: ci/$PROJECT_NAME:$BRANCH_NAME_TAG_SAFE-$BUILD_NUMBER - environment: - CI: - RETRIES: - MONGO_HOST: mongo - POSTGRES_HOST: postgres - GCS_API_ENDPOINT: http://gcs:9090 - GCS_PROJECT_ID: fake - STORAGE_EMULATOR_HOST: http://gcs:9090/storage/v1 - MOCHA_GREP: ${MOCHA_GREP} - NODE_ENV: test - NODE_OPTIONS: "--unhandled-rejections=strict" - volumes: - - ./reports:/overleaf/services/docstore/reports - - ../../bin/shared/wait_for_it:/overleaf/bin/shared/wait_for_it - depends_on: - mongo: - condition: service_started - gcs: - condition: service_healthy - user: node - entrypoint: /overleaf/bin/shared/wait_for_it mongo:27017 --timeout=60 -- - command: yarn run test:acceptance - - tar: - build: . - image: ci/$PROJECT_NAME:$BRANCH_NAME_TAG_SAFE-$BUILD_NUMBER - volumes: - - ./:/tmp/build/ - command: tar -czf /tmp/build/build.tar.gz --exclude=build.tar.gz --exclude-vcs . - user: root - - mongo: - image: mongo:8.0.11 - command: --replSet overleaf --notablescan - volumes: - - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js - - ../../bin/shared/mongodb-docker-entrypoint-wait.sh:/mongodb-docker-entrypoint-wait.sh - entrypoint: /mongodb-docker-entrypoint-wait.sh - environment: - MONGO_INITDB_DATABASE: sharelatex - extra_hosts: - # Required when using the automatic database setup for initializing the - # replica set. This override is not needed when running the setup after - # starting up mongo. - - mongo:127.0.0.1 - - gcs: - image: fsouza/fake-gcs-server:1.52.3 - command: ["--port=9090", "--scheme=http", "--external-url=http://gcs:9090"] - healthcheck: - test: wget --quiet --output-document=/dev/null http://localhost:9090/storage/v1/b - interval: 1s - retries: 20 diff --git a/services/docstore/docker-compose.yml b/services/docstore/docker-compose.yml deleted file mode 100644 index a3789640a1..0000000000 --- a/services/docstore/docker-compose.yml +++ /dev/null @@ -1,93 +0,0 @@ -# This file was auto-generated, do not edit it directly. -# Instead run bin/update_build_scripts from -# https://github.com/overleaf/internal/ - -services: - test_unit: - build: - context: ../.. - dockerfile: services/docstore/Dockerfile - target: base - volumes: - - .:/overleaf/services/docstore - - ../../node_modules:/overleaf/node_modules - - ../../libraries:/overleaf/libraries - - ../../package.json:/overleaf/package.json - - ../../.yarnrc.yml:/overleaf/.yarnrc.yml - - ../../yarn.lock:/overleaf/yarn.lock - - ../../.yarn:/overleaf/.yarn - - ../../bin/shared/wait_for_it:/overleaf/bin/shared/wait_for_it - - ../../tools/migrations:/overleaf/tools/migrations - - ../../tsconfig.backend.json:/overleaf/tsconfig.backend.json - working_dir: /overleaf/services/docstore - environment: - MOCHA_GREP: ${MOCHA_GREP} - LOG_LEVEL: ${LOG_LEVEL:-} - MONGO_CONNECTION_STRING: mongodb://mongo/test-overleaf - NODE_ENV: test - NODE_OPTIONS: "--unhandled-rejections=strict" - entrypoint: /overleaf/bin/shared/wait_for_it mongo:27017 --timeout=60 -- - command: yarn run --silent test:unit - user: node - depends_on: - mongo: - condition: service_started - - test_acceptance: - build: - context: ../.. - dockerfile: services/docstore/Dockerfile - target: base - volumes: - - .:/overleaf/services/docstore - - ../../node_modules:/overleaf/node_modules - - ../../libraries:/overleaf/libraries - - ../../package.json:/overleaf/package.json - - ../../.yarnrc.yml:/overleaf/.yarnrc.yml - - ../../yarn.lock:/overleaf/yarn.lock - - ../../.yarn:/overleaf/.yarn - - ../../bin/shared/wait_for_it:/overleaf/bin/shared/wait_for_it - - ../../tools/migrations:/overleaf/tools/migrations - working_dir: /overleaf/services/docstore - environment: - RETRIES: - MONGO_CONNECTION_STRING: mongodb://mongo/test-overleaf - POSTGRES_HOST: postgres - GCS_API_ENDPOINT: http://gcs:9090 - GCS_PROJECT_ID: fake - STORAGE_EMULATOR_HOST: http://gcs:9090/storage/v1 - MOCHA_GREP: ${MOCHA_GREP} - LOG_LEVEL: ${LOG_LEVEL:-} - NODE_ENV: test - NODE_OPTIONS: "--unhandled-rejections=strict" - user: node - depends_on: - mongo: - condition: service_started - gcs: - condition: service_healthy - entrypoint: /overleaf/bin/shared/wait_for_it mongo:27017 --timeout=60 -- - command: yarn run --silent test:acceptance - - mongo: - image: mongo:8.0.11 - command: --replSet overleaf --notablescan - volumes: - - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js - - ../../bin/shared/mongodb-docker-entrypoint-wait.sh:/mongodb-docker-entrypoint-wait.sh - entrypoint: /mongodb-docker-entrypoint-wait.sh - environment: - MONGO_INITDB_DATABASE: sharelatex - extra_hosts: - # Required when using the automatic database setup for initializing the - # replica set. This override is not needed when running the setup after - # starting up mongo. - - mongo:127.0.0.1 - - gcs: - image: fsouza/fake-gcs-server:1.52.3 - command: ["--port=9090", "--scheme=http", "--external-url=http://gcs:9090"] - healthcheck: - test: wget --quiet --output-document=/dev/null http://localhost:9090/storage/v1/b - interval: 1s - retries: 20 diff --git a/services/document-updater/.nvmrc b/services/document-updater/.nvmrc deleted file mode 100644 index 8e35034890..0000000000 --- a/services/document-updater/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -24.14.1 diff --git a/services/document-updater/Dockerfile b/services/document-updater/Dockerfile deleted file mode 100644 index 07c80ca19e..0000000000 --- a/services/document-updater/Dockerfile +++ /dev/null @@ -1,56 +0,0 @@ -# This file was auto-generated, do not edit it directly. -# Instead run bin/update_build_scripts from -# https://github.com/overleaf/internal/ - -FROM node:24.14.1 AS base - -# Corepack setup, shared between all the images. -ENV PATH="/overleaf/node_modules/.bin:$PATH" -ENV COREPACK_HOME=/opt/corepack -RUN corepack enable && corepack install -g yarn@4.14.1 -ENV COREPACK_ENABLE_NETWORK=0 - -WORKDIR /overleaf/services/document-updater - -# Google Cloud Storage needs a writable $HOME/.config for resumable uploads -# (see https://googleapis.dev/nodejs/storage/latest/File.html#createWriteStream) -RUN mkdir /home/node/.config && chown node:node /home/node/.config - -FROM base AS app - -COPY package.json yarn.lock .yarnrc.yml /overleaf/ -COPY libraries/eslint-plugin/package.json /overleaf/libraries/eslint-plugin/package.json -COPY libraries/fetch-utils/package.json /overleaf/libraries/fetch-utils/package.json -COPY libraries/logger/package.json /overleaf/libraries/logger/package.json -COPY libraries/metrics/package.json /overleaf/libraries/metrics/package.json -COPY libraries/mongo-utils/package.json /overleaf/libraries/mongo-utils/package.json -COPY libraries/o-error/package.json /overleaf/libraries/o-error/package.json -COPY libraries/overleaf-editor-core/package.json /overleaf/libraries/overleaf-editor-core/package.json -COPY libraries/promise-utils/package.json /overleaf/libraries/promise-utils/package.json -COPY libraries/ranges-tracker/package.json /overleaf/libraries/ranges-tracker/package.json -COPY libraries/redis-wrapper/package.json /overleaf/libraries/redis-wrapper/package.json -COPY libraries/settings/package.json /overleaf/libraries/settings/package.json -COPY services/document-updater/package.json /overleaf/services/document-updater/package.json -COPY tools/migrations/package.json /overleaf/tools/migrations/package.json -COPY .yarn/patches/ /overleaf/.yarn/patches/ -COPY tools/migrations/ /overleaf/tools/migrations/ - -RUN cd /overleaf && yarn workspaces focus @overleaf/document-updater overleaf -COPY libraries/eslint-plugin/ /overleaf/libraries/eslint-plugin/ -COPY libraries/fetch-utils/ /overleaf/libraries/fetch-utils/ -COPY libraries/logger/ /overleaf/libraries/logger/ -COPY libraries/metrics/ /overleaf/libraries/metrics/ -COPY libraries/mongo-utils/ /overleaf/libraries/mongo-utils/ -COPY libraries/o-error/ /overleaf/libraries/o-error/ -COPY libraries/overleaf-editor-core/ /overleaf/libraries/overleaf-editor-core/ -COPY libraries/promise-utils/ /overleaf/libraries/promise-utils/ -COPY libraries/ranges-tracker/ /overleaf/libraries/ranges-tracker/ -COPY libraries/redis-wrapper/ /overleaf/libraries/redis-wrapper/ -COPY libraries/settings/ /overleaf/libraries/settings/ -COPY services/document-updater/ /overleaf/services/document-updater/ -COPY tools/migrations/ /overleaf/tools/migrations/ - -FROM app -USER node - -CMD ["node", "--expose-gc", "app.js"] diff --git a/services/document-updater/Makefile b/services/document-updater/Makefile deleted file mode 100644 index b436b7a5bf..0000000000 --- a/services/document-updater/Makefile +++ /dev/null @@ -1,208 +0,0 @@ -# This file was auto-generated, do not edit it directly. -# Instead run bin/update_build_scripts from -# https://github.com/overleaf/internal/ - -export BUILD_NUMBER ?= local -export BRANCH_NAME ?= $(shell git rev-parse --abbrev-ref HEAD) -export BRANCH_NAME_TAG_SAFE = $(shell echo $(BRANCH_NAME) | sed 's/\//\-\-/g' | cut -c1-96) -export PROJECT_NAME = document-updater -BUILD_DIR_NAME = $(shell pwd | xargs basename | tr -cd '[a-zA-Z0-9_.\-]') -HERE=$(shell pwd) -export MONOREPO ?= $(shell cd ../../ && pwd) -export RETRIES ?= $(if $(CI),3,0) -IMAGE_CI ?= ci/$(PROJECT_NAME):$(BRANCH_NAME_TAG_SAFE)-$(BUILD_NUMBER) -IMAGE_REPO ?= us-east1-docker.pkg.dev/overleaf-ops/ol-docker/$(PROJECT_NAME) -IMAGE_REPO_FINAL ?= $(IMAGE_REPO):$(BRANCH_NAME_TAG_SAFE)-$(BUILD_NUMBER) -IMAGE_CACHE ?= $(IMAGE_REPO):cache-$(shell cat \ - $(MONOREPO)/package.json \ - $(MONOREPO)/yarn.lock \ - $(MONOREPO)/libraries/eslint-plugin/package.json \ - $(MONOREPO)/libraries/fetch-utils/package.json \ - $(MONOREPO)/libraries/logger/package.json \ - $(MONOREPO)/libraries/metrics/package.json \ - $(MONOREPO)/libraries/mongo-utils/package.json \ - $(MONOREPO)/libraries/o-error/package.json \ - $(MONOREPO)/libraries/overleaf-editor-core/package.json \ - $(MONOREPO)/libraries/promise-utils/package.json \ - $(MONOREPO)/libraries/ranges-tracker/package.json \ - $(MONOREPO)/libraries/redis-wrapper/package.json \ - $(MONOREPO)/libraries/settings/package.json \ - $(MONOREPO)/services/document-updater/package.json \ - $(MONOREPO)/tools/migrations/package.json \ - $(MONOREPO)/.yarn/patches/* \ -| sha256sum | cut -d '-' -f1) -export MOCHA_GREP ?= - -DOCKER_COMPOSE_FLAGS ?= -f docker-compose.yml -DOCKER_COMPOSE := docker compose ${DOCKER_COMPOSE_FLAGS} - -# Common flags for docker compose run. -# --rm Tear down test runner container -# --build Make sure that we use the latest base image -# --quiet-build Hide build output -ifeq ($(CI),true) - DC_RUN_FLAGS := --rm -else - DC_RUN_FLAGS := --rm --build --quiet-build -endif - -COMPOSE_PROJECT_NAME_TEST_ACCEPTANCE ?= test_acceptance_$(BUILD_DIR_NAME) -DOCKER_COMPOSE_TEST_ACCEPTANCE = \ - COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME_TEST_ACCEPTANCE) $(DOCKER_COMPOSE) - -COMPOSE_PROJECT_NAME_TEST_UNIT ?= test_unit_$(BUILD_DIR_NAME) -DOCKER_COMPOSE_TEST_UNIT = \ - COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME_TEST_UNIT) $(DOCKER_COMPOSE) - -.PHONY: print-branch-tag-safe -print-branch-tag-safe: - @echo $(BRANCH_NAME_TAG_SAFE) - -clean: - -docker rmi $(IMAGE_CI) - -docker rmi $(IMAGE_REPO_FINAL) - -$(DOCKER_COMPOSE_TEST_UNIT) down --remove-orphans --rmi local --timeout 0 --volumes - -$(DOCKER_COMPOSE_TEST_ACCEPTANCE) down --remove-orphans --rmi local --timeout 0 --volumes - -rm -rf reports/ - -# Run the linting commands in the scope of the monorepo. -# Eslint and prettier (plus some configs) are on the root. -RUN_LINTING = ../../bin/run -w /overleaf/services/$(PROJECT_NAME) monorepo yarn run --silent -RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent - -RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/document-updater/reports:/overleaf/services/document-updater/reports --volume $(MONOREPO)/.cache:/overleaf/.cache $(IMAGE_CI) yarn run --silent -RUN_LINTING_CI_MONOREPO = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/document-updater/reports:/overleaf/services/document-updater/reports --volume $(MONOREPO)/.cache:/overleaf/.cache -w /overleaf $(IMAGE_CI) yarn run --silent - -SHELLCHECK_OPTS = \ - --shell=bash \ - --external-sources -SHELLCHECK_COLOR := $(if $(CI),--color=never,--color) -SHELLCHECK_FILES := { git ls-files "*.sh" -z; git grep -Plz "\A\#\!.*bash"; } | sort -zu - -shellcheck: - @$(SHELLCHECK_FILES) | xargs -0 -r docker run --rm -v $(HERE):/mnt -w /mnt \ - koalaman/shellcheck:stable $(SHELLCHECK_OPTS) $(SHELLCHECK_COLOR) - -shellcheck_fix: - @$(SHELLCHECK_FILES) | while IFS= read -r -d '' file; do \ - diff=$$(docker run --rm -v $(HERE):/mnt -w /mnt koalaman/shellcheck:stable $(SHELLCHECK_OPTS) --format=diff "$$file" 2>/dev/null); \ - if [ -n "$$diff" ] && ! echo "$$diff" | patch -p1 >/dev/null 2>&1; then echo "\033[31m$$file\033[0m"; \ - elif [ -n "$$diff" ]; then echo "$$file"; \ - else echo "\033[2m$$file\033[0m"; fi \ - done - -WITH_CACHE_FOLDER = \ - lint \ - lint_ci \ - lint_fix \ - format \ - format_ci \ - format_fix \ - typecheck \ - typecheck_ci \ - -$(WITH_CACHE_FOLDER): ../../.cache/prettier/ -../../.cache/prettier/: - mkdir -p $@ - -$(WITH_CACHE_FOLDER): ../../.cache/eslint/ -../../.cache/eslint/: - mkdir -p $@ - -format: - $(RUN_LINTING_MONOREPO) format services/document-updater - -format_ci: - $(RUN_LINTING_CI_MONOREPO) format services/document-updater - -format_fix: - $(RUN_LINTING_MONOREPO) format:fix services/document-updater - -lint: - $(RUN_LINTING) lint - -lint_ci: - -$(RUN_LINTING_CI) lint --format json --output-file reports/eslint.json - sed -i 's_"filePath":"/overleaf_"filePath":"$(MONOREPO)_g' reports/eslint.json - -lint_fix: - $(RUN_LINTING) lint:fix - -typecheck: - $(RUN_LINTING) types:check - -typecheck_ci: - $(RUN_LINTING_CI) types:check - -test: format lint typecheck shellcheck test_unit test_acceptance - -test_unit: -ifneq (,$(wildcard test/unit)) - $(DOCKER_COMPOSE_TEST_UNIT) run $(DC_RUN_FLAGS) test_unit -endif - -test_clean: test_unit_clean -test_unit_clean: -ifneq (,$(wildcard test/unit)) - $(DOCKER_COMPOSE_TEST_UNIT) down -v -t 0 -endif - -test_acceptance: test_acceptance_clean test_acceptance_pre_run test_acceptance_run - $(MAKE) test_acceptance_clean - -test_acceptance_debug: test_acceptance_clean test_acceptance_pre_run test_acceptance_run_debug - $(MAKE) test_acceptance_clean - -test_acceptance_run: -ifneq (,$(wildcard test/acceptance)) - $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run $(DC_RUN_FLAGS) test_acceptance -endif - -test_acceptance_run_debug: -ifneq (,$(wildcard test/acceptance)) - $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run -p 127.0.0.9:19999:19999 $(DC_RUN_FLAGS) test_acceptance yarn run --inspect=0.0.0.0:19999 --inspect-brk test:acceptance -endif - -test_clean: test_acceptance_clean -test_acceptance_clean: - $(DOCKER_COMPOSE_TEST_ACCEPTANCE) down -v -t 0 - -test_acceptance_pre_run: -ifneq (,$(wildcard test/acceptance/js/scripts/pre-run)) - $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run $(DC_RUN_FLAGS) test_acceptance test/acceptance/js/scripts/pre-run -endif - -benchmarks: - $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run $(DC_RUN_FLAGS) test_acceptance yarn run benchmarks - -build: - docker build \ - --pull \ - --build-arg BUILDKIT_INLINE_CACHE=1 \ - --tag $(IMAGE_CI) \ - --tag $(IMAGE_CACHE) \ - --tag $(IMAGE_REPO_FINAL) \ - --cache-from $(IMAGE_CACHE) \ - --file Dockerfile \ - ../.. - -tar: - $(DOCKER_COMPOSE) up tar - -push: - docker push $(IMAGE_REPO_FINAL) - -push_branch: - docker push $(IMAGE_CACHE) - -.PHONY: clean \ - format format_fix \ - lint lint_fix \ - build_types typecheck \ - lint_ci format_ci typecheck_ci \ - shellcheck shellcheck_fix \ - test test_clean test_unit test_unit_clean \ - test_acceptance test_acceptance_debug test_acceptance_pre_run \ - test_acceptance_run test_acceptance_run_debug test_acceptance_clean \ - benchmarks \ - build tar publish \ diff --git a/services/document-updater/buildscript.txt b/services/document-updater/buildscript.txt deleted file mode 100644 index a90fc19c49..0000000000 --- a/services/document-updater/buildscript.txt +++ /dev/null @@ -1,9 +0,0 @@ -document-updater ---dependencies=mongo,redis ---deploy-pipeline=document-updater ---env-add= ---env-pass-through= ---esmock-loader=False ---node-version=24.14.1 ---pipeline-owner=44 ---public-repo=True diff --git a/services/document-updater/docker-compose.ci.yml b/services/document-updater/docker-compose.ci.yml deleted file mode 100644 index b091d49a9b..0000000000 --- a/services/document-updater/docker-compose.ci.yml +++ /dev/null @@ -1,85 +0,0 @@ -# This file was auto-generated, do not edit it directly. -# Instead run bin/update_build_scripts from -# https://github.com/overleaf/internal/ - -services: - test_unit: - image: ci/$PROJECT_NAME:$BRANCH_NAME_TAG_SAFE-$BUILD_NUMBER - user: node - volumes: - - ./reports:/overleaf/services/document-updater/reports - - ../../bin/shared/wait_for_it:/overleaf/bin/shared/wait_for_it - entrypoint: /overleaf/bin/shared/wait_for_it mongo:27017 --timeout=60 -- - command: yarn run test:unit:_run - environment: - CI: - MONGO_CONNECTION_STRING: mongodb://mongo/test-overleaf - NODE_ENV: test - NODE_OPTIONS: "--unhandled-rejections=strict" - REDIS_HOST: redis_test - QUEUES_REDIS_HOST: redis_test - HISTORY_REDIS_HOST: redis_test - ANALYTICS_QUEUES_REDIS_HOST: redis_test - ANALYTICS_QUEUES_BQ_SINK_REDIS_HOST: redis_test - depends_on: - mongo: - condition: service_started - redis_test: - condition: service_healthy - - test_acceptance: - image: ci/$PROJECT_NAME:$BRANCH_NAME_TAG_SAFE-$BUILD_NUMBER - environment: - CI: - RETRIES: - REDIS_HOST: redis_test - QUEUES_REDIS_HOST: redis_test - HISTORY_REDIS_HOST: redis_test - ANALYTICS_QUEUES_REDIS_HOST: redis_test - ANALYTICS_QUEUES_BQ_SINK_REDIS_HOST: redis_test - MONGO_HOST: mongo - POSTGRES_HOST: postgres - MOCHA_GREP: ${MOCHA_GREP} - NODE_ENV: test - NODE_OPTIONS: "--unhandled-rejections=strict" - volumes: - - ./reports:/overleaf/services/document-updater/reports - - ../../bin/shared/wait_for_it:/overleaf/bin/shared/wait_for_it - depends_on: - mongo: - condition: service_started - redis_test: - condition: service_healthy - user: node - entrypoint: /overleaf/bin/shared/wait_for_it mongo:27017 --timeout=60 -- - command: yarn run test:acceptance - - tar: - build: . - image: ci/$PROJECT_NAME:$BRANCH_NAME_TAG_SAFE-$BUILD_NUMBER - volumes: - - ./:/tmp/build/ - command: tar -czf /tmp/build/build.tar.gz --exclude=build.tar.gz --exclude-vcs . - user: root - - redis_test: - image: redis:7.4.8 - healthcheck: - test: ping="$$(redis-cli ping)" && [ "$$ping" = 'PONG' ] - interval: 1s - retries: 20 - - mongo: - image: mongo:8.0.11 - command: --replSet overleaf --notablescan - volumes: - - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js - - ../../bin/shared/mongodb-docker-entrypoint-wait.sh:/mongodb-docker-entrypoint-wait.sh - entrypoint: /mongodb-docker-entrypoint-wait.sh - environment: - MONGO_INITDB_DATABASE: sharelatex - extra_hosts: - # Required when using the automatic database setup for initializing the - # replica set. This override is not needed when running the setup after - # starting up mongo. - - mongo:127.0.0.1 diff --git a/services/document-updater/docker-compose.yml b/services/document-updater/docker-compose.yml deleted file mode 100644 index 7c577603c0..0000000000 --- a/services/document-updater/docker-compose.yml +++ /dev/null @@ -1,100 +0,0 @@ -# This file was auto-generated, do not edit it directly. -# Instead run bin/update_build_scripts from -# https://github.com/overleaf/internal/ - -services: - test_unit: - build: - context: ../.. - dockerfile: services/document-updater/Dockerfile - target: base - volumes: - - .:/overleaf/services/document-updater - - ../../node_modules:/overleaf/node_modules - - ../../libraries:/overleaf/libraries - - ../../package.json:/overleaf/package.json - - ../../.yarnrc.yml:/overleaf/.yarnrc.yml - - ../../yarn.lock:/overleaf/yarn.lock - - ../../.yarn:/overleaf/.yarn - - ../../bin/shared/wait_for_it:/overleaf/bin/shared/wait_for_it - - ../../tools/migrations:/overleaf/tools/migrations - working_dir: /overleaf/services/document-updater - environment: - MOCHA_GREP: ${MOCHA_GREP} - LOG_LEVEL: ${LOG_LEVEL:-} - MONGO_CONNECTION_STRING: mongodb://mongo/test-overleaf - NODE_ENV: test - NODE_OPTIONS: "--unhandled-rejections=strict" - REDIS_HOST: redis_test - HISTORY_REDIS_HOST: redis_test - QUEUES_REDIS_HOST: redis_test - ANALYTICS_QUEUES_REDIS_HOST: redis_test - ANALYTICS_QUEUES_BQ_SINK_REDIS_HOST: redis_test - entrypoint: /overleaf/bin/shared/wait_for_it mongo:27017 --timeout=60 -- - command: yarn run --silent test:unit - user: node - depends_on: - mongo: - condition: service_started - redis_test: - condition: service_healthy - - test_acceptance: - build: - context: ../.. - dockerfile: services/document-updater/Dockerfile - target: base - volumes: - - .:/overleaf/services/document-updater - - ../../node_modules:/overleaf/node_modules - - ../../libraries:/overleaf/libraries - - ../../package.json:/overleaf/package.json - - ../../.yarnrc.yml:/overleaf/.yarnrc.yml - - ../../yarn.lock:/overleaf/yarn.lock - - ../../.yarn:/overleaf/.yarn - - ../../bin/shared/wait_for_it:/overleaf/bin/shared/wait_for_it - - ../../tools/migrations:/overleaf/tools/migrations - working_dir: /overleaf/services/document-updater - environment: - RETRIES: - REDIS_HOST: redis_test - HISTORY_REDIS_HOST: redis_test - QUEUES_REDIS_HOST: redis_test - ANALYTICS_QUEUES_REDIS_HOST: redis_test - ANALYTICS_QUEUES_BQ_SINK_REDIS_HOST: redis_test - MONGO_CONNECTION_STRING: mongodb://mongo/test-overleaf - POSTGRES_HOST: postgres - MOCHA_GREP: ${MOCHA_GREP} - LOG_LEVEL: ${LOG_LEVEL:-} - NODE_ENV: test - NODE_OPTIONS: "--unhandled-rejections=strict" - user: node - depends_on: - mongo: - condition: service_started - redis_test: - condition: service_healthy - entrypoint: /overleaf/bin/shared/wait_for_it mongo:27017 --timeout=60 -- - command: yarn run --silent test:acceptance - - redis_test: - image: redis:7.4.8 - healthcheck: - test: ping=$$(redis-cli ping) && [ "$$ping" = 'PONG' ] - interval: 1s - retries: 20 - - mongo: - image: mongo:8.0.11 - command: --replSet overleaf --notablescan - volumes: - - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js - - ../../bin/shared/mongodb-docker-entrypoint-wait.sh:/mongodb-docker-entrypoint-wait.sh - entrypoint: /mongodb-docker-entrypoint-wait.sh - environment: - MONGO_INITDB_DATABASE: sharelatex - extra_hosts: - # Required when using the automatic database setup for initializing the - # replica set. This override is not needed when running the setup after - # starting up mongo. - - mongo:127.0.0.1 diff --git a/services/filestore/.nvmrc b/services/filestore/.nvmrc deleted file mode 100644 index 8e35034890..0000000000 --- a/services/filestore/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -24.14.1 diff --git a/services/filestore/Dockerfile b/services/filestore/Dockerfile deleted file mode 100644 index c3445ae8a1..0000000000 --- a/services/filestore/Dockerfile +++ /dev/null @@ -1,51 +0,0 @@ -# This file was auto-generated, do not edit it directly. -# Instead run bin/update_build_scripts from -# https://github.com/overleaf/internal/ - -FROM node:24.14.1 AS base - -# Corepack setup, shared between all the images. -ENV PATH="/overleaf/node_modules/.bin:$PATH" -ENV COREPACK_HOME=/opt/corepack -RUN corepack enable && corepack install -g yarn@4.14.1 -ENV COREPACK_ENABLE_NETWORK=0 - -WORKDIR /overleaf/services/filestore -COPY services/filestore/install_deps.sh /overleaf/services/filestore/ -RUN chmod 0755 ./install_deps.sh && ./install_deps.sh - -# Google Cloud Storage needs a writable $HOME/.config for resumable uploads -# (see https://googleapis.dev/nodejs/storage/latest/File.html#createWriteStream) -RUN mkdir /home/node/.config && chown node:node /home/node/.config - -FROM base AS app - -COPY package.json yarn.lock .yarnrc.yml /overleaf/ -COPY libraries/eslint-plugin/package.json /overleaf/libraries/eslint-plugin/package.json -COPY libraries/fetch-utils/package.json /overleaf/libraries/fetch-utils/package.json -COPY libraries/logger/package.json /overleaf/libraries/logger/package.json -COPY libraries/metrics/package.json /overleaf/libraries/metrics/package.json -COPY libraries/o-error/package.json /overleaf/libraries/o-error/package.json -COPY libraries/object-persistor/package.json /overleaf/libraries/object-persistor/package.json -COPY libraries/settings/package.json /overleaf/libraries/settings/package.json -COPY libraries/stream-utils/package.json /overleaf/libraries/stream-utils/package.json -COPY services/filestore/package.json /overleaf/services/filestore/package.json -COPY .yarn/patches/ /overleaf/.yarn/patches/ - -RUN cd /overleaf && yarn workspaces focus @overleaf/filestore overleaf -COPY libraries/eslint-plugin/ /overleaf/libraries/eslint-plugin/ -COPY libraries/fetch-utils/ /overleaf/libraries/fetch-utils/ -COPY libraries/logger/ /overleaf/libraries/logger/ -COPY libraries/metrics/ /overleaf/libraries/metrics/ -COPY libraries/o-error/ /overleaf/libraries/o-error/ -COPY libraries/object-persistor/ /overleaf/libraries/object-persistor/ -COPY libraries/settings/ /overleaf/libraries/settings/ -COPY libraries/stream-utils/ /overleaf/libraries/stream-utils/ -COPY services/filestore/ /overleaf/services/filestore/ - -FROM app -RUN mkdir -p uploads template_files \ -&& chown node:node uploads template_files -USER node - -CMD ["node", "--expose-gc", "app.js"] diff --git a/services/filestore/Makefile b/services/filestore/Makefile deleted file mode 100644 index 4cd37a5e9b..0000000000 --- a/services/filestore/Makefile +++ /dev/null @@ -1,205 +0,0 @@ -# This file was auto-generated, do not edit it directly. -# Instead run bin/update_build_scripts from -# https://github.com/overleaf/internal/ - -export BUILD_NUMBER ?= local -export BRANCH_NAME ?= $(shell git rev-parse --abbrev-ref HEAD) -export BRANCH_NAME_TAG_SAFE = $(shell echo $(BRANCH_NAME) | sed 's/\//\-\-/g' | cut -c1-96) -export PROJECT_NAME = filestore -BUILD_DIR_NAME = $(shell pwd | xargs basename | tr -cd '[a-zA-Z0-9_.\-]') -HERE=$(shell pwd) -export MONOREPO ?= $(shell cd ../../ && pwd) -export RETRIES ?= $(if $(CI),3,0) -IMAGE_CI ?= ci/$(PROJECT_NAME):$(BRANCH_NAME_TAG_SAFE)-$(BUILD_NUMBER) -IMAGE_REPO ?= us-east1-docker.pkg.dev/overleaf-ops/ol-docker/$(PROJECT_NAME) -IMAGE_REPO_FINAL ?= $(IMAGE_REPO):$(BRANCH_NAME_TAG_SAFE)-$(BUILD_NUMBER) -IMAGE_CACHE ?= $(IMAGE_REPO):cache-$(shell cat \ - $(MONOREPO)/package.json \ - $(MONOREPO)/yarn.lock \ - $(MONOREPO)/libraries/eslint-plugin/package.json \ - $(MONOREPO)/libraries/fetch-utils/package.json \ - $(MONOREPO)/libraries/logger/package.json \ - $(MONOREPO)/libraries/metrics/package.json \ - $(MONOREPO)/libraries/o-error/package.json \ - $(MONOREPO)/libraries/object-persistor/package.json \ - $(MONOREPO)/libraries/settings/package.json \ - $(MONOREPO)/libraries/stream-utils/package.json \ - $(MONOREPO)/services/filestore/package.json \ - $(MONOREPO)/.yarn/patches/* \ -| sha256sum | cut -d '-' -f1) -export MOCHA_GREP ?= - -DOCKER_COMPOSE_FLAGS ?= -f docker-compose.yml -DOCKER_COMPOSE := docker compose ${DOCKER_COMPOSE_FLAGS} - -# Common flags for docker compose run. -# --rm Tear down test runner container -# --build Make sure that we use the latest base image -# --quiet-build Hide build output -ifeq ($(CI),true) - DC_RUN_FLAGS := --rm -else - DC_RUN_FLAGS := --rm --build --quiet-build -endif - -COMPOSE_PROJECT_NAME_TEST_ACCEPTANCE ?= test_acceptance_$(BUILD_DIR_NAME) -DOCKER_COMPOSE_TEST_ACCEPTANCE = \ - COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME_TEST_ACCEPTANCE) $(DOCKER_COMPOSE) - -COMPOSE_PROJECT_NAME_TEST_UNIT ?= test_unit_$(BUILD_DIR_NAME) -DOCKER_COMPOSE_TEST_UNIT = \ - COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME_TEST_UNIT) $(DOCKER_COMPOSE) - -.PHONY: print-branch-tag-safe -print-branch-tag-safe: - @echo $(BRANCH_NAME_TAG_SAFE) - -clean: - -docker rmi $(IMAGE_CI) - -docker rmi $(IMAGE_REPO_FINAL) - -$(DOCKER_COMPOSE_TEST_UNIT) down --remove-orphans --rmi local --timeout 0 --volumes - -$(DOCKER_COMPOSE_TEST_ACCEPTANCE) down --remove-orphans --rmi local --timeout 0 --volumes - -rm -rf reports/ - -git clean -dfX uploads template_files - -# Run the linting commands in the scope of the monorepo. -# Eslint and prettier (plus some configs) are on the root. -RUN_LINTING = ../../bin/run -w /overleaf/services/$(PROJECT_NAME) monorepo yarn run --silent -RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent - -RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/filestore/reports:/overleaf/services/filestore/reports --volume $(MONOREPO)/.cache:/overleaf/.cache $(IMAGE_CI) yarn run --silent -RUN_LINTING_CI_MONOREPO = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/filestore/reports:/overleaf/services/filestore/reports --volume $(MONOREPO)/.cache:/overleaf/.cache -w /overleaf $(IMAGE_CI) yarn run --silent - -SHELLCHECK_OPTS = \ - --shell=bash \ - --external-sources -SHELLCHECK_COLOR := $(if $(CI),--color=never,--color) -SHELLCHECK_FILES := { git ls-files "*.sh" -z; git grep -Plz "\A\#\!.*bash"; } | sort -zu - -shellcheck: - @$(SHELLCHECK_FILES) | xargs -0 -r docker run --rm -v $(HERE):/mnt -w /mnt \ - koalaman/shellcheck:stable $(SHELLCHECK_OPTS) $(SHELLCHECK_COLOR) - -shellcheck_fix: - @$(SHELLCHECK_FILES) | while IFS= read -r -d '' file; do \ - diff=$$(docker run --rm -v $(HERE):/mnt -w /mnt koalaman/shellcheck:stable $(SHELLCHECK_OPTS) --format=diff "$$file" 2>/dev/null); \ - if [ -n "$$diff" ] && ! echo "$$diff" | patch -p1 >/dev/null 2>&1; then echo "\033[31m$$file\033[0m"; \ - elif [ -n "$$diff" ]; then echo "$$file"; \ - else echo "\033[2m$$file\033[0m"; fi \ - done - -WITH_CACHE_FOLDER = \ - lint \ - lint_ci \ - lint_fix \ - format \ - format_ci \ - format_fix \ - typecheck \ - typecheck_ci \ - -$(WITH_CACHE_FOLDER): ../../.cache/prettier/ -../../.cache/prettier/: - mkdir -p $@ - -$(WITH_CACHE_FOLDER): ../../.cache/eslint/ -../../.cache/eslint/: - mkdir -p $@ - -format: - $(RUN_LINTING_MONOREPO) format services/filestore - -format_ci: - $(RUN_LINTING_CI_MONOREPO) format services/filestore - -format_fix: - $(RUN_LINTING_MONOREPO) format:fix services/filestore - -lint: - $(RUN_LINTING) lint - -lint_ci: - -$(RUN_LINTING_CI) lint --format json --output-file reports/eslint.json - sed -i 's_"filePath":"/overleaf_"filePath":"$(MONOREPO)_g' reports/eslint.json - -lint_fix: - $(RUN_LINTING) lint:fix - -typecheck: - $(RUN_LINTING) types:check - -typecheck_ci: - $(RUN_LINTING_CI) types:check - -test: format lint typecheck shellcheck test_unit test_acceptance - -test_unit: -ifneq (,$(wildcard test/unit)) - $(DOCKER_COMPOSE_TEST_UNIT) run $(DC_RUN_FLAGS) test_unit -endif - -test_clean: test_unit_clean -test_unit_clean: -ifneq (,$(wildcard test/unit)) - $(DOCKER_COMPOSE_TEST_UNIT) down -v -t 0 -endif - -test_acceptance: test_acceptance_clean test_acceptance_pre_run test_acceptance_run - $(MAKE) test_acceptance_clean - -test_acceptance_debug: test_acceptance_clean test_acceptance_pre_run test_acceptance_run_debug - $(MAKE) test_acceptance_clean - -test_acceptance_run: -ifneq (,$(wildcard test/acceptance)) - $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run $(DC_RUN_FLAGS) test_acceptance -endif - -test_acceptance_run_debug: -ifneq (,$(wildcard test/acceptance)) - $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run -p 127.0.0.9:19999:19999 $(DC_RUN_FLAGS) test_acceptance yarn run --inspect=0.0.0.0:19999 --inspect-brk test:acceptance -endif - -test_clean: test_acceptance_clean -test_acceptance_clean: - $(DOCKER_COMPOSE_TEST_ACCEPTANCE) down -v -t 0 - -test_acceptance_pre_run: -ifneq (,$(wildcard test/acceptance/js/scripts/pre-run)) - $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run $(DC_RUN_FLAGS) test_acceptance test/acceptance/js/scripts/pre-run -endif - -benchmarks: - $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run $(DC_RUN_FLAGS) test_acceptance yarn run benchmarks - -build: - docker build \ - --pull \ - --build-arg BUILDKIT_INLINE_CACHE=1 \ - --tag $(IMAGE_CI) \ - --tag $(IMAGE_CACHE) \ - --tag $(IMAGE_REPO_FINAL) \ - --cache-from $(IMAGE_CACHE) \ - --file Dockerfile \ - ../.. - -tar: - $(DOCKER_COMPOSE) up tar - -push: - docker push $(IMAGE_REPO_FINAL) - -push_branch: - docker push $(IMAGE_CACHE) - -.PHONY: clean \ - format format_fix \ - lint lint_fix \ - build_types typecheck \ - lint_ci format_ci typecheck_ci \ - shellcheck shellcheck_fix \ - test test_clean test_unit test_unit_clean \ - test_acceptance test_acceptance_debug test_acceptance_pre_run \ - test_acceptance_run test_acceptance_run_debug test_acceptance_clean \ - benchmarks \ - build tar publish \ diff --git a/services/filestore/buildscript.txt b/services/filestore/buildscript.txt deleted file mode 100644 index 2b5a51e890..0000000000 --- a/services/filestore/buildscript.txt +++ /dev/null @@ -1,14 +0,0 @@ -filestore ---data-dirs=uploads,template_files ---dependencies=s3,gcs ---deploy-pipeline=filestore-readonly ---env-add=ENABLE_CONVERSIONS=true,USE_PROM_METRICS=true,AWS_S3_USER_FILES_STORAGE_CLASS=REDUCED_REDUNDANCY,AWS_S3_USER_FILES_BUCKET_NAME=fake-user-files,AWS_S3_USER_FILES_DEK_BUCKET_NAME=fake-user-files-dek,AWS_S3_TEMPLATE_FILES_BUCKET_NAME=fake-template-files,GCS_USER_FILES_BUCKET_NAME=fake-gcs-user-files,GCS_TEMPLATE_FILES_BUCKET_NAME=fake-gcs-template-files ---env-pass-through= ---esmock-loader=False ---node-version=24.14.1 ---pipeline-owner=32 ---public-repo=True ---test-acceptance-shards=SHARD_01_,SHARD_02_,SHARD_03_ ---test-unit-vitest=True ---tsconfig-extra-includes=vitest.config.unit.cjs,vitest.config.acceptance.cjs ---use-large-ci-runner=True diff --git a/services/filestore/docker-compose.ci.yml b/services/filestore/docker-compose.ci.yml deleted file mode 100644 index 70b93bc473..0000000000 --- a/services/filestore/docker-compose.ci.yml +++ /dev/null @@ -1,227 +0,0 @@ -# This file was auto-generated, do not edit it directly. -# Instead run bin/update_build_scripts from -# https://github.com/overleaf/internal/ - -volumes: - minio-certs: - -services: - test_unit: - image: ci/$PROJECT_NAME:$BRANCH_NAME_TAG_SAFE-$BUILD_NUMBER - user: node - volumes: - - ./reports:/overleaf/services/filestore/reports - - ../../tsconfig.backend.json:/overleaf/tsconfig.backend.json - command: yarn run test:unit:_run - environment: - CI: - MONGO_CONNECTION_STRING: mongodb://mongo/test-overleaf - NODE_ENV: test - NODE_OPTIONS: "--unhandled-rejections=strict" - VITEST_NO_CACHE: true - - test_acceptance: - image: ci/$PROJECT_NAME:$BRANCH_NAME_TAG_SAFE-$BUILD_NUMBER - environment: - CI: - RETRIES: - MONGO_HOST: mongo - POSTGRES_HOST: postgres - AWS_S3_ENDPOINT: https://minio:9000 - AWS_S3_PATH_STYLE: "true" - DELETE_OBJECTS_MD5_FALLBACK: true - AWS_ACCESS_KEY_ID: OVERLEAF_FILESTORE_S3_ACCESS_KEY_ID - AWS_SECRET_ACCESS_KEY: OVERLEAF_FILESTORE_S3_SECRET_ACCESS_KEY - MINIO_ROOT_USER: MINIO_ROOT_USER - MINIO_ROOT_PASSWORD: MINIO_ROOT_PASSWORD - GCS_API_ENDPOINT: http://gcs:9090 - GCS_PROJECT_ID: fake - STORAGE_EMULATOR_HOST: http://gcs:9090/storage/v1 - MOCHA_GREP: ${MOCHA_GREP} - NODE_ENV: test - NODE_OPTIONS: "--unhandled-rejections=strict" - ENABLE_CONVERSIONS: true - USE_PROM_METRICS: true - AWS_S3_USER_FILES_STORAGE_CLASS: REDUCED_REDUNDANCY - AWS_S3_USER_FILES_BUCKET_NAME: fake-user-files - AWS_S3_USER_FILES_DEK_BUCKET_NAME: fake-user-files-dek - AWS_S3_TEMPLATE_FILES_BUCKET_NAME: fake-template-files - GCS_USER_FILES_BUCKET_NAME: fake-gcs-user-files - GCS_TEMPLATE_FILES_BUCKET_NAME: fake-gcs-template-files - volumes: - - ./reports:/overleaf/services/filestore/reports - - minio-certs:/certs - depends_on: - certs: - condition: service_completed_successfully - minio: - condition: service_started - minio_setup: - condition: service_completed_successfully - gcs: - condition: service_healthy - user: node - command: yarn run test:acceptance - - tar: - build: . - image: ci/$PROJECT_NAME:$BRANCH_NAME_TAG_SAFE-$BUILD_NUMBER - volumes: - - ./:/tmp/build/ - command: tar -czf /tmp/build/build.tar.gz --exclude=build.tar.gz --exclude-vcs . - user: root - - certs: - build: - dockerfile_inline: | - FROM node:24.14.1 - RUN wget -O /certgen "https://github.com/minio/certgen/releases/download/v1.3.0/certgen-linux-"`dpkg --print-architecture` - RUN chmod +x /certgen - volumes: - - minio-certs:/certs - working_dir: /certs - entrypoint: sh - command: - - "-cex" - - | - if [ ! -f private.key ] || [ ! -f public.crt ]; then - /certgen -host minio - fi - if ! openssl x509 -checkend 864000 -noout -in public.crt > /dev/null; then - /certgen -host minio - fi - - minio: - image: minio/minio:RELEASE.2024-10-13T13-34-11Z - command: server /data - volumes: - - minio-certs:/root/.minio/certs - environment: - MINIO_ROOT_USER: MINIO_ROOT_USER - MINIO_ROOT_PASSWORD: MINIO_ROOT_PASSWORD - depends_on: - certs: - condition: service_completed_successfully - - minio_setup: - depends_on: - certs: - condition: service_completed_successfully - minio: - condition: service_started - image: minio/mc:RELEASE.2024-10-08T09-37-26Z - volumes: - - minio-certs:/root/.mc/certs/CAs - entrypoint: sh - command: - - "-cex" - - | - sleep 1 - mc alias set s3 https://minio:9000 MINIO_ROOT_USER MINIO_ROOT_PASSWORD \ - || sleep 3 && \ - mc alias set s3 https://minio:9000 MINIO_ROOT_USER MINIO_ROOT_PASSWORD \ - || sleep 3 && \ - mc alias set s3 https://minio:9000 MINIO_ROOT_USER MINIO_ROOT_PASSWORD \ - || sleep 3 && \ - mc alias set s3 https://minio:9000 MINIO_ROOT_USER MINIO_ROOT_PASSWORD - mc mb --ignore-existing s3/fake-user-files - mc mb --ignore-existing s3/fake-user-files-dek - mc mb --ignore-existing s3/fake-template-files - mc admin user add s3 \ - OVERLEAF_FILESTORE_S3_ACCESS_KEY_ID \ - OVERLEAF_FILESTORE_S3_SECRET_ACCESS_KEY - - echo ' - { - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": [ - "s3:ListBucket" - ], - "Resource": "arn:aws:s3:::fake-user-files" - }, - { - "Effect": "Allow", - "Action": [ - "s3:PutObject", - "s3:GetObject", - "s3:DeleteObject", - "s3:AbortMultipartUpload", - "s3:ListMultipartUploadParts", - "s3:ListBucketMultipartUploads" - ], - "Resource": "arn:aws:s3:::fake-user-files/*" - }, - { - "Effect": "Allow", - "Action": [ - "s3:ListBucket" - ], - "Resource": "arn:aws:s3:::fake-user-files-dek" - }, - { - "Effect": "Allow", - "Action": [ - "s3:PutObject", - "s3:GetObject", - "s3:DeleteObject", - "s3:AbortMultipartUpload", - "s3:ListMultipartUploadParts", - "s3:ListBucketMultipartUploads" - ], - "Resource": "arn:aws:s3:::fake-user-files-dek/*" - }, - { - "Effect": "Allow", - "Action": [ - "s3:ListBucket" - ], - "Resource": "arn:aws:s3:::fake-template-files" - }, - { - "Effect": "Allow", - "Action": [ - "s3:PutObject", - "s3:GetObject", - "s3:DeleteObject", - "s3:AbortMultipartUpload", - "s3:ListMultipartUploadParts", - "s3:ListBucketMultipartUploads" - ], - "Resource": "arn:aws:s3:::fake-template-files/*" - }, - { - "Effect": "Allow", - "Action": [ - "s3:ListBucket" - ], - "Resource": "arn:aws:s3:::random-bucket-*" - }, - { - "Effect": "Allow", - "Action": [ - "s3:PutObject", - "s3:GetObject", - "s3:DeleteObject", - "s3:AbortMultipartUpload", - "s3:ListMultipartUploadParts", - "s3:ListBucketMultipartUploads" - ], - "Resource": "arn:aws:s3:::random-bucket-*" - } - ] - }' > policy-filestore.json - - mc admin policy create s3 overleaf-filestore policy-filestore.json - mc admin policy attach s3 overleaf-filestore \ - --user=OVERLEAF_FILESTORE_S3_ACCESS_KEY_ID - - gcs: - image: fsouza/fake-gcs-server:1.52.3 - command: ["--port=9090", "--scheme=http", "--external-url=http://gcs:9090"] - healthcheck: - test: wget --quiet --output-document=/dev/null http://localhost:9090/storage/v1/b - interval: 1s - retries: 20 diff --git a/services/filestore/docker-compose.yml b/services/filestore/docker-compose.yml deleted file mode 100644 index 4629a5a0cf..0000000000 --- a/services/filestore/docker-compose.yml +++ /dev/null @@ -1,239 +0,0 @@ -# This file was auto-generated, do not edit it directly. -# Instead run bin/update_build_scripts from -# https://github.com/overleaf/internal/ - -volumes: - minio-certs: - -services: - test_unit: - build: - context: ../.. - dockerfile: services/filestore/Dockerfile - target: base - volumes: - - .:/overleaf/services/filestore - - ../../node_modules:/overleaf/node_modules - - ../../libraries:/overleaf/libraries - - ../../package.json:/overleaf/package.json - - ../../.yarnrc.yml:/overleaf/.yarnrc.yml - - ../../yarn.lock:/overleaf/yarn.lock - - ../../.yarn:/overleaf/.yarn - - ../../tsconfig.backend.json:/overleaf/tsconfig.backend.json - working_dir: /overleaf/services/filestore - environment: - MOCHA_GREP: ${MOCHA_GREP} - LOG_LEVEL: ${LOG_LEVEL:-} - MONGO_CONNECTION_STRING: mongodb://mongo/test-overleaf - NODE_ENV: test - NODE_OPTIONS: "--unhandled-rejections=strict" - command: yarn run --silent test:unit - user: node - - test_acceptance: - build: - context: ../.. - dockerfile: services/filestore/Dockerfile - target: base - volumes: - - .:/overleaf/services/filestore - - ../../node_modules:/overleaf/node_modules - - ../../libraries:/overleaf/libraries - - ../../package.json:/overleaf/package.json - - ../../.yarnrc.yml:/overleaf/.yarnrc.yml - - ../../yarn.lock:/overleaf/yarn.lock - - ../../.yarn:/overleaf/.yarn - - minio-certs:/certs - working_dir: /overleaf/services/filestore - environment: - RETRIES: - MONGO_CONNECTION_STRING: mongodb://mongo/test-overleaf - POSTGRES_HOST: postgres - AWS_S3_ENDPOINT: https://minio:9000 - AWS_S3_PATH_STYLE: "true" - DELETE_OBJECTS_MD5_FALLBACK: true - AWS_ACCESS_KEY_ID: OVERLEAF_FILESTORE_S3_ACCESS_KEY_ID - AWS_SECRET_ACCESS_KEY: OVERLEAF_FILESTORE_S3_SECRET_ACCESS_KEY - MINIO_ROOT_USER: MINIO_ROOT_USER - MINIO_ROOT_PASSWORD: MINIO_ROOT_PASSWORD - GCS_API_ENDPOINT: http://gcs:9090 - GCS_PROJECT_ID: fake - STORAGE_EMULATOR_HOST: http://gcs:9090/storage/v1 - MOCHA_GREP: ${MOCHA_GREP} - LOG_LEVEL: ${LOG_LEVEL:-} - NODE_ENV: test - NODE_OPTIONS: "--unhandled-rejections=strict" - ENABLE_CONVERSIONS: true - USE_PROM_METRICS: true - AWS_S3_USER_FILES_STORAGE_CLASS: REDUCED_REDUNDANCY - AWS_S3_USER_FILES_BUCKET_NAME: fake-user-files - AWS_S3_USER_FILES_DEK_BUCKET_NAME: fake-user-files-dek - AWS_S3_TEMPLATE_FILES_BUCKET_NAME: fake-template-files - GCS_USER_FILES_BUCKET_NAME: fake-gcs-user-files - GCS_TEMPLATE_FILES_BUCKET_NAME: fake-gcs-template-files - user: node - depends_on: - certs: - condition: service_completed_successfully - minio: - condition: service_started - minio_setup: - condition: service_completed_successfully - gcs: - condition: service_healthy - command: yarn run --silent test:acceptance - - certs: - build: - dockerfile_inline: | - FROM node:24.14.1 - RUN wget -O /certgen "https://github.com/minio/certgen/releases/download/v1.3.0/certgen-linux-"`dpkg --print-architecture` - RUN chmod +x /certgen - volumes: - - minio-certs:/certs - working_dir: /certs - entrypoint: sh - command: - - "-cex" - - | - if [ ! -f private.key ] || [ ! -f public.crt ]; then - /certgen -host minio - fi - if ! openssl x509 -checkend 864000 -noout -in public.crt > /dev/null; then - /certgen -host minio - fi - - minio: - image: minio/minio:RELEASE.2024-10-13T13-34-11Z - command: server /data - volumes: - - minio-certs:/root/.minio/certs - environment: - MINIO_ROOT_USER: MINIO_ROOT_USER - MINIO_ROOT_PASSWORD: MINIO_ROOT_PASSWORD - depends_on: - certs: - condition: service_completed_successfully - - minio_setup: - depends_on: - certs: - condition: service_completed_successfully - minio: - condition: service_started - image: minio/mc:RELEASE.2024-10-08T09-37-26Z - volumes: - - minio-certs:/root/.mc/certs/CAs - entrypoint: sh - command: - - "-cex" - - | - sleep 1 - mc alias set s3 https://minio:9000 MINIO_ROOT_USER MINIO_ROOT_PASSWORD \ - || sleep 3 && \ - mc alias set s3 https://minio:9000 MINIO_ROOT_USER MINIO_ROOT_PASSWORD \ - || sleep 3 && \ - mc alias set s3 https://minio:9000 MINIO_ROOT_USER MINIO_ROOT_PASSWORD \ - || sleep 3 && \ - mc alias set s3 https://minio:9000 MINIO_ROOT_USER MINIO_ROOT_PASSWORD - mc mb --ignore-existing s3/fake-user-files - mc mb --ignore-existing s3/fake-user-files-dek - mc mb --ignore-existing s3/fake-template-files - mc admin user add s3 \ - OVERLEAF_FILESTORE_S3_ACCESS_KEY_ID \ - OVERLEAF_FILESTORE_S3_SECRET_ACCESS_KEY - - echo ' - { - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": [ - "s3:ListBucket" - ], - "Resource": "arn:aws:s3:::fake-user-files" - }, - { - "Effect": "Allow", - "Action": [ - "s3:PutObject", - "s3:GetObject", - "s3:DeleteObject", - "s3:AbortMultipartUpload", - "s3:ListMultipartUploadParts", - "s3:ListBucketMultipartUploads" - ], - "Resource": "arn:aws:s3:::fake-user-files/*" - }, - { - "Effect": "Allow", - "Action": [ - "s3:ListBucket" - ], - "Resource": "arn:aws:s3:::fake-user-files-dek" - }, - { - "Effect": "Allow", - "Action": [ - "s3:PutObject", - "s3:GetObject", - "s3:DeleteObject", - "s3:AbortMultipartUpload", - "s3:ListMultipartUploadParts", - "s3:ListBucketMultipartUploads" - ], - "Resource": "arn:aws:s3:::fake-user-files-dek/*" - }, - { - "Effect": "Allow", - "Action": [ - "s3:ListBucket" - ], - "Resource": "arn:aws:s3:::fake-template-files" - }, - { - "Effect": "Allow", - "Action": [ - "s3:PutObject", - "s3:GetObject", - "s3:DeleteObject", - "s3:AbortMultipartUpload", - "s3:ListMultipartUploadParts", - "s3:ListBucketMultipartUploads" - ], - "Resource": "arn:aws:s3:::fake-template-files/*" - }, - { - "Effect": "Allow", - "Action": [ - "s3:ListBucket" - ], - "Resource": "arn:aws:s3:::random-bucket-*" - }, - { - "Effect": "Allow", - "Action": [ - "s3:PutObject", - "s3:GetObject", - "s3:DeleteObject", - "s3:AbortMultipartUpload", - "s3:ListMultipartUploadParts", - "s3:ListBucketMultipartUploads" - ], - "Resource": "arn:aws:s3:::random-bucket-*" - } - ] - }' > policy-filestore.json - - mc admin policy create s3 overleaf-filestore policy-filestore.json - mc admin policy attach s3 overleaf-filestore \ - --user=OVERLEAF_FILESTORE_S3_ACCESS_KEY_ID - - gcs: - image: fsouza/fake-gcs-server:1.52.3 - command: ["--port=9090", "--scheme=http", "--external-url=http://gcs:9090"] - healthcheck: - test: wget --quiet --output-document=/dev/null http://localhost:9090/storage/v1/b - interval: 1s - retries: 20 diff --git a/services/git-bridge/Dockerfile b/services/git-bridge/Dockerfile deleted file mode 100644 index ab139ec7f9..0000000000 --- a/services/git-bridge/Dockerfile +++ /dev/null @@ -1,69 +0,0 @@ -# Build the a8m/envsubst binary, as it supports default values, -# which the gnu envsubst (from gettext-base) does not. -FROM golang:1.25.5-alpine AS envsubst_builder - -WORKDIR /build - -RUN go install github.com/a8m/envsubst/cmd/envsubst@latest - -FROM maven:3-amazoncorretto-21-debian AS base - -RUN apt-get update && apt-get install -y make git sqlite3 curl \ - && rm -rf /var/lib/apt/lists - -COPY --from=envsubst_builder /go/bin/envsubst /opt/envsubst -RUN chmod +x /opt/envsubst - -RUN useradd --create-home node - -ENV MAVEN_OPTS="-Dmaven.repo.local=/home/node/repository" - -# Pre-resolve Maven dependencies and plugins into the base image to limit -# downstream `mvn` invocations hitting Maven Central on every CI build. -# Uses a stub source and test, since `dependency:go-offline` and `dependency:resolve-plugins` -# miss lazy-loaded plugins (surefire, maven-assembly, etc.) -COPY pom.xml /tmp/warmup/pom.xml -RUN mkdir -p /tmp/warmup/src/main/java/stub /tmp/warmup/src/test/java/stub \ - && echo 'package stub; public class Stub {}' > /tmp/warmup/src/main/java/stub/Stub.java \ - && echo 'package stub; public class StubTest { @org.junit.Test public void noop() {} }' \ - > /tmp/warmup/src/test/java/stub/StubTest.java \ -&& mvn -B --no-transfer-progress -f /tmp/warmup/pom.xml clean package \ -&& chown -R node:node /home/node/repository \ -&& rm -rf /tmp/warmup - -FROM base AS builder - -COPY . /app - -WORKDIR /app - -RUN make package \ -# The name of the created jar contains the current version tag. -# Rename it to a static path that can be used for copying. -&& find /app/target \ - -name 'writelatex-git-bridge*jar-with-dependencies.jar' \ - -exec mv {} /git-bridge.jar \; - -FROM amazoncorretto:21-alpine - -RUN apk add --update --no-cache bash git sqlite procps htop net-tools jemalloc util-linux - -ENV LD_PRELOAD=/usr/lib/libjemalloc.so.2 - -RUN adduser -D node - -COPY --from=builder /git-bridge.jar / - -COPY --from=envsubst_builder /go/bin/envsubst /opt/envsubst -RUN chmod +x /opt/envsubst - -COPY conf/envsubst_template.json envsubst_template.json -COPY start.sh start.sh -COPY server-pro-start.sh server-pro-start.sh - -RUN mkdir conf -RUN chown node:node conf - -USER node - -CMD ["/start.sh"] diff --git a/services/git-bridge/Makefile b/services/git-bridge/Makefile deleted file mode 100644 index 7c773d8dd1..0000000000 --- a/services/git-bridge/Makefile +++ /dev/null @@ -1,93 +0,0 @@ -# git-bridge makefile - -MVN_OPTS := --no-transfer-progress -MVN_TARGET := target/writelatex-git-bridge-1.0-SNAPSHOT-jar-with-dependencies.jar - -export BUILD_NUMBER ?= local -export BRANCH_NAME ?= $(shell git rev-parse --abbrev-ref HEAD) -export BRANCH_NAME_TAG_SAFE = $(shell echo $(BRANCH_NAME) | sed 's/\//\-\-/g' | cut -c1-96) -export COMMIT_SHA ?= $(shell git rev-parse HEAD) -PROJECT_NAME = git-bridge - -IMAGE_CI ?= ci/$(PROJECT_NAME):$(BRANCH_NAME_TAG_SAFE)-$(BUILD_NUMBER) -IMAGE_REPO ?= us-east1-docker.pkg.dev/overleaf-ops/ol-docker/$(PROJECT_NAME) -IMAGE_REPO_BRANCH ?= $(IMAGE_REPO):$(BRANCH_NAME_TAG_SAFE) -IMAGE_REPO_MAIN ?= $(IMAGE_REPO):main -IMAGE_REPO_FINAL ?= $(IMAGE_REPO_BRANCH)-$(BUILD_NUMBER) - -.PHONY: print-branch-tag-safe -print-branch-tag-safe: - @echo $(BRANCH_NAME_TAG_SAFE) - -runtime-conf: - /opt/envsubst < conf/envsubst_template.json > conf/runtime.json - - -run: $(MVN_TARGET) runtime-conf - @exec java $(GIT_BRIDGE_JVM_ARGS) -jar $(MVN_TARGET) conf/runtime.json - - -$(MVN_TARGET): $(shell find src -type f) pom.xml - mvn $(MVN_OPTS) package -DskipTests - -build: $(MVN_TARGET) - -docker_build_base: - docker build --pull \ - --build-arg BUILDKIT_INLINE_CACHE=1 \ - --cache-from $(IMAGE_REPO_BRANCH) \ - --cache-from $(IMAGE_REPO_MAIN) \ - --tag $(IMAGE_CI) \ - --target base \ - . - -docker_build: - docker build --pull \ - --build-arg BUILDKIT_INLINE_CACHE=1 \ - --cache-from $(IMAGE_REPO_BRANCH) \ - --cache-from $(IMAGE_REPO_MAIN) \ - --tag $(IMAGE_REPO_BRANCH) \ - --tag $(IMAGE_REPO_FINAL) \ - . - -format: - mvn $(MVN_OPTS) com.spotify.fmt:fmt-maven-plugin:check - -docker_format: - docker run --rm -v $(PWD):$(PWD) -w $(PWD) --user node $(IMAGE_CI) make format - -format_fix: - mvn $(MVN_OPTS) com.spotify.fmt:fmt-maven-plugin:format - - -test: - mvn $(MVN_OPTS) test - -docker_test: - docker run --rm -v $(PWD):$(PWD) -w $(PWD) --user node $(IMAGE_CI) make test - -clean: - mvn $(MVN_OPTS) clean - - -package: clean - mvn $(MVN_OPTS) package -DskipTests - -refresh_cache: refresh_cache_branch -refresh_cache_branch: - docker inspect $(IMAGE_REPO_BRANCH) > /dev/null && docker pull $(IMAGE_REPO_BRANCH) || true -refresh_cache: refresh_cache_latest -refresh_cache_latest: - docker inspect $(IMAGE_REPO_MAIN) > /dev/null && docker pull $(IMAGE_REPO_MAIN) || true - -push: - docker push $(IMAGE_REPO_FINAL) - -push_branch: - docker push $(IMAGE_REPO_BRANCH) - -clean_ci: - -docker rmi -f $(IMAGE_CI) $(IMAGE_REPO_FINAL) - -git clean -xdf . - -.PHONY: run package build clean test runtime-conf diff --git a/services/history-v1/.nvmrc b/services/history-v1/.nvmrc deleted file mode 100644 index 8e35034890..0000000000 --- a/services/history-v1/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -24.14.1 diff --git a/services/history-v1/Dockerfile b/services/history-v1/Dockerfile deleted file mode 100644 index dee30c5f19..0000000000 --- a/services/history-v1/Dockerfile +++ /dev/null @@ -1,65 +0,0 @@ -# This file was auto-generated, do not edit it directly. -# Instead run bin/update_build_scripts from -# https://github.com/overleaf/internal/ - -FROM node:24.14.1 AS base - -# Corepack setup, shared between all the images. -ENV PATH="/overleaf/node_modules/.bin:$PATH" -ENV COREPACK_HOME=/opt/corepack -RUN corepack enable && corepack install -g yarn@4.14.1 -ENV COREPACK_ENABLE_NETWORK=0 - -WORKDIR /overleaf/services/history-v1 -COPY services/history-v1/install_deps.sh /overleaf/services/history-v1/ -RUN chmod 0755 ./install_deps.sh && ./install_deps.sh - -# Google Cloud Storage needs a writable $HOME/.config for resumable uploads -# (see https://googleapis.dev/nodejs/storage/latest/File.html#createWriteStream) -RUN mkdir /home/node/.config && chown node:node /home/node/.config - -# fs persistor needs a writable folder as a target for the mounted volume -RUN mkdir /buckets && chown node:node /buckets - -FROM base AS app - -COPY package.json yarn.lock .yarnrc.yml /overleaf/ -COPY libraries/eslint-plugin/package.json /overleaf/libraries/eslint-plugin/package.json -COPY libraries/fetch-utils/package.json /overleaf/libraries/fetch-utils/package.json -COPY libraries/logger/package.json /overleaf/libraries/logger/package.json -COPY libraries/metrics/package.json /overleaf/libraries/metrics/package.json -COPY libraries/mongo-utils/package.json /overleaf/libraries/mongo-utils/package.json -COPY libraries/o-error/package.json /overleaf/libraries/o-error/package.json -COPY libraries/object-persistor/package.json /overleaf/libraries/object-persistor/package.json -COPY libraries/overleaf-editor-core/package.json /overleaf/libraries/overleaf-editor-core/package.json -COPY libraries/promise-utils/package.json /overleaf/libraries/promise-utils/package.json -COPY libraries/redis-wrapper/package.json /overleaf/libraries/redis-wrapper/package.json -COPY libraries/settings/package.json /overleaf/libraries/settings/package.json -COPY libraries/stream-utils/package.json /overleaf/libraries/stream-utils/package.json -COPY libraries/validation-tools/package.json /overleaf/libraries/validation-tools/package.json -COPY services/history-v1/package.json /overleaf/services/history-v1/package.json -COPY tools/migrations/package.json /overleaf/tools/migrations/package.json -COPY .yarn/patches/ /overleaf/.yarn/patches/ -COPY tools/migrations/ /overleaf/tools/migrations/ - -RUN cd /overleaf && yarn workspaces focus overleaf-editor overleaf -COPY libraries/eslint-plugin/ /overleaf/libraries/eslint-plugin/ -COPY libraries/fetch-utils/ /overleaf/libraries/fetch-utils/ -COPY libraries/logger/ /overleaf/libraries/logger/ -COPY libraries/metrics/ /overleaf/libraries/metrics/ -COPY libraries/mongo-utils/ /overleaf/libraries/mongo-utils/ -COPY libraries/o-error/ /overleaf/libraries/o-error/ -COPY libraries/object-persistor/ /overleaf/libraries/object-persistor/ -COPY libraries/overleaf-editor-core/ /overleaf/libraries/overleaf-editor-core/ -COPY libraries/promise-utils/ /overleaf/libraries/promise-utils/ -COPY libraries/redis-wrapper/ /overleaf/libraries/redis-wrapper/ -COPY libraries/settings/ /overleaf/libraries/settings/ -COPY libraries/stream-utils/ /overleaf/libraries/stream-utils/ -COPY libraries/validation-tools/ /overleaf/libraries/validation-tools/ -COPY services/history-v1/ /overleaf/services/history-v1/ -COPY tools/migrations/ /overleaf/tools/migrations/ - -FROM app -USER node - -CMD ["node", "--expose-gc", "app.js"] diff --git a/services/history-v1/Makefile b/services/history-v1/Makefile deleted file mode 100644 index b3a2bd6281..0000000000 --- a/services/history-v1/Makefile +++ /dev/null @@ -1,210 +0,0 @@ -# This file was auto-generated, do not edit it directly. -# Instead run bin/update_build_scripts from -# https://github.com/overleaf/internal/ - -export BUILD_NUMBER ?= local -export BRANCH_NAME ?= $(shell git rev-parse --abbrev-ref HEAD) -export BRANCH_NAME_TAG_SAFE = $(shell echo $(BRANCH_NAME) | sed 's/\//\-\-/g' | cut -c1-96) -export PROJECT_NAME = history-v1 -BUILD_DIR_NAME = $(shell pwd | xargs basename | tr -cd '[a-zA-Z0-9_.\-]') -HERE=$(shell pwd) -export MONOREPO ?= $(shell cd ../../ && pwd) -export RETRIES ?= $(if $(CI),3,0) -IMAGE_CI ?= ci/$(PROJECT_NAME):$(BRANCH_NAME_TAG_SAFE)-$(BUILD_NUMBER) -IMAGE_REPO ?= us-east1-docker.pkg.dev/overleaf-ops/ol-docker/$(PROJECT_NAME) -IMAGE_REPO_FINAL ?= $(IMAGE_REPO):$(BRANCH_NAME_TAG_SAFE)-$(BUILD_NUMBER) -IMAGE_CACHE ?= $(IMAGE_REPO):cache-$(shell cat \ - $(MONOREPO)/package.json \ - $(MONOREPO)/yarn.lock \ - $(MONOREPO)/libraries/eslint-plugin/package.json \ - $(MONOREPO)/libraries/fetch-utils/package.json \ - $(MONOREPO)/libraries/logger/package.json \ - $(MONOREPO)/libraries/metrics/package.json \ - $(MONOREPO)/libraries/mongo-utils/package.json \ - $(MONOREPO)/libraries/o-error/package.json \ - $(MONOREPO)/libraries/object-persistor/package.json \ - $(MONOREPO)/libraries/overleaf-editor-core/package.json \ - $(MONOREPO)/libraries/promise-utils/package.json \ - $(MONOREPO)/libraries/redis-wrapper/package.json \ - $(MONOREPO)/libraries/settings/package.json \ - $(MONOREPO)/libraries/stream-utils/package.json \ - $(MONOREPO)/libraries/validation-tools/package.json \ - $(MONOREPO)/services/history-v1/package.json \ - $(MONOREPO)/tools/migrations/package.json \ - $(MONOREPO)/.yarn/patches/* \ -| sha256sum | cut -d '-' -f1) -export MOCHA_GREP ?= - -DOCKER_COMPOSE_FLAGS ?= -f docker-compose.yml -DOCKER_COMPOSE := docker compose ${DOCKER_COMPOSE_FLAGS} - -# Common flags for docker compose run. -# --rm Tear down test runner container -# --build Make sure that we use the latest base image -# --quiet-build Hide build output -ifeq ($(CI),true) - DC_RUN_FLAGS := --rm -else - DC_RUN_FLAGS := --rm --build --quiet-build -endif - -COMPOSE_PROJECT_NAME_TEST_ACCEPTANCE ?= test_acceptance_$(BUILD_DIR_NAME) -DOCKER_COMPOSE_TEST_ACCEPTANCE = \ - COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME_TEST_ACCEPTANCE) $(DOCKER_COMPOSE) - -COMPOSE_PROJECT_NAME_TEST_UNIT ?= test_unit_$(BUILD_DIR_NAME) -DOCKER_COMPOSE_TEST_UNIT = \ - COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME_TEST_UNIT) $(DOCKER_COMPOSE) - -.PHONY: print-branch-tag-safe -print-branch-tag-safe: - @echo $(BRANCH_NAME_TAG_SAFE) - -clean: - -docker rmi $(IMAGE_CI) - -docker rmi $(IMAGE_REPO_FINAL) - -$(DOCKER_COMPOSE_TEST_UNIT) down --remove-orphans --rmi local --timeout 0 --volumes - -$(DOCKER_COMPOSE_TEST_ACCEPTANCE) down --remove-orphans --rmi local --timeout 0 --volumes - -rm -rf reports/ - -# Run the linting commands in the scope of the monorepo. -# Eslint and prettier (plus some configs) are on the root. -RUN_LINTING = ../../bin/run -w /overleaf/services/$(PROJECT_NAME) monorepo yarn run --silent -RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent - -RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/history-v1/reports:/overleaf/services/history-v1/reports --volume $(MONOREPO)/.cache:/overleaf/.cache $(IMAGE_CI) yarn run --silent -RUN_LINTING_CI_MONOREPO = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/history-v1/reports:/overleaf/services/history-v1/reports --volume $(MONOREPO)/.cache:/overleaf/.cache -w /overleaf $(IMAGE_CI) yarn run --silent - -SHELLCHECK_OPTS = \ - --shell=bash \ - --external-sources -SHELLCHECK_COLOR := $(if $(CI),--color=never,--color) -SHELLCHECK_FILES := { git ls-files "*.sh" -z; git grep -Plz "\A\#\!.*bash"; } | sort -zu - -shellcheck: - @$(SHELLCHECK_FILES) | xargs -0 -r docker run --rm -v $(HERE):/mnt -w /mnt \ - koalaman/shellcheck:stable $(SHELLCHECK_OPTS) $(SHELLCHECK_COLOR) - -shellcheck_fix: - @$(SHELLCHECK_FILES) | while IFS= read -r -d '' file; do \ - diff=$$(docker run --rm -v $(HERE):/mnt -w /mnt koalaman/shellcheck:stable $(SHELLCHECK_OPTS) --format=diff "$$file" 2>/dev/null); \ - if [ -n "$$diff" ] && ! echo "$$diff" | patch -p1 >/dev/null 2>&1; then echo "\033[31m$$file\033[0m"; \ - elif [ -n "$$diff" ]; then echo "$$file"; \ - else echo "\033[2m$$file\033[0m"; fi \ - done - -WITH_CACHE_FOLDER = \ - lint \ - lint_ci \ - lint_fix \ - format \ - format_ci \ - format_fix \ - typecheck \ - typecheck_ci \ - -$(WITH_CACHE_FOLDER): ../../.cache/prettier/ -../../.cache/prettier/: - mkdir -p $@ - -$(WITH_CACHE_FOLDER): ../../.cache/eslint/ -../../.cache/eslint/: - mkdir -p $@ - -format: - $(RUN_LINTING_MONOREPO) format services/history-v1 - -format_ci: - $(RUN_LINTING_CI_MONOREPO) format services/history-v1 - -format_fix: - $(RUN_LINTING_MONOREPO) format:fix services/history-v1 - -lint: - $(RUN_LINTING) lint - -lint_ci: - -$(RUN_LINTING_CI) lint --format json --output-file reports/eslint.json - sed -i 's_"filePath":"/overleaf_"filePath":"$(MONOREPO)_g' reports/eslint.json - -lint_fix: - $(RUN_LINTING) lint:fix - -typecheck: - $(RUN_LINTING) types:check - -typecheck_ci: - $(RUN_LINTING_CI) types:check - -test: format lint typecheck shellcheck test_unit test_acceptance - -test_unit: -ifneq (,$(wildcard test/unit)) - $(DOCKER_COMPOSE_TEST_UNIT) run $(DC_RUN_FLAGS) test_unit -endif - -test_clean: test_unit_clean -test_unit_clean: -ifneq (,$(wildcard test/unit)) - $(DOCKER_COMPOSE_TEST_UNIT) down -v -t 0 -endif - -test_acceptance: test_acceptance_clean test_acceptance_pre_run test_acceptance_run - $(MAKE) test_acceptance_clean - -test_acceptance_debug: test_acceptance_clean test_acceptance_pre_run test_acceptance_run_debug - $(MAKE) test_acceptance_clean - -test_acceptance_run: -ifneq (,$(wildcard test/acceptance)) - $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run $(DC_RUN_FLAGS) test_acceptance -endif - -test_acceptance_run_debug: -ifneq (,$(wildcard test/acceptance)) - $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run -p 127.0.0.9:19999:19999 $(DC_RUN_FLAGS) test_acceptance yarn run --inspect=0.0.0.0:19999 --inspect-brk test:acceptance -endif - -test_clean: test_acceptance_clean -test_acceptance_clean: - $(DOCKER_COMPOSE_TEST_ACCEPTANCE) down -v -t 0 - -test_acceptance_pre_run: -ifneq (,$(wildcard test/acceptance/js/scripts/pre-run)) - $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run $(DC_RUN_FLAGS) test_acceptance test/acceptance/js/scripts/pre-run -endif - -benchmarks: - $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run $(DC_RUN_FLAGS) test_acceptance yarn run benchmarks - -build: - docker build \ - --pull \ - --build-arg BUILDKIT_INLINE_CACHE=1 \ - --tag $(IMAGE_CI) \ - --tag $(IMAGE_CACHE) \ - --tag $(IMAGE_REPO_FINAL) \ - --cache-from $(IMAGE_CACHE) \ - --file Dockerfile \ - ../.. - -tar: - $(DOCKER_COMPOSE) up tar - -push: - docker push $(IMAGE_REPO_FINAL) - -push_branch: - docker push $(IMAGE_CACHE) - -.PHONY: clean \ - format format_fix \ - lint lint_fix \ - build_types typecheck \ - lint_ci format_ci typecheck_ci \ - shellcheck shellcheck_fix \ - test test_clean test_unit test_unit_clean \ - test_acceptance test_acceptance_debug test_acceptance_pre_run \ - test_acceptance_run test_acceptance_run_debug test_acceptance_clean \ - benchmarks \ - build tar publish \ diff --git a/services/history-v1/buildscript.txt b/services/history-v1/buildscript.txt deleted file mode 100644 index b711aa44d7..0000000000 --- a/services/history-v1/buildscript.txt +++ /dev/null @@ -1,10 +0,0 @@ -history-v1 ---dependencies=postgres,gcs,mongo,redis,s3 ---deploy-pipeline=history-v1 ---env-add= ---env-pass-through= ---esmock-loader=False ---node-version=24.14.1 ---pipeline-owner=44 ---public-repo=False ---tsconfig-extra-includes=backup-deletion-app.mjs,backup-verifier-app.mjs,backup-worker-app.mjs,api/**/*,migrations/**/*,storage/**/* diff --git a/services/history-v1/docker-compose.ci.yml b/services/history-v1/docker-compose.ci.yml deleted file mode 100644 index 3c5b768489..0000000000 --- a/services/history-v1/docker-compose.ci.yml +++ /dev/null @@ -1,277 +0,0 @@ -# This file was auto-generated, do not edit it directly. -# Instead run bin/update_build_scripts from -# https://github.com/overleaf/internal/ - -volumes: - minio-certs: - -services: - test_unit: - image: ci/$PROJECT_NAME:$BRANCH_NAME_TAG_SAFE-$BUILD_NUMBER - user: node - volumes: - - ./reports:/overleaf/services/history-v1/reports - - ../../bin/shared/wait_for_it:/overleaf/bin/shared/wait_for_it - entrypoint: /overleaf/bin/shared/wait_for_it mongo:27017 --timeout=60 -- - command: yarn run test:unit:_run - environment: - CI: - MONGO_CONNECTION_STRING: mongodb://mongo/test-overleaf - NODE_ENV: test - NODE_OPTIONS: "--unhandled-rejections=strict" - REDIS_HOST: redis_test - QUEUES_REDIS_HOST: redis_test - HISTORY_REDIS_HOST: redis_test - ANALYTICS_QUEUES_REDIS_HOST: redis_test - ANALYTICS_QUEUES_BQ_SINK_REDIS_HOST: redis_test - depends_on: - mongo: - condition: service_started - redis_test: - condition: service_healthy - - test_acceptance: - image: ci/$PROJECT_NAME:$BRANCH_NAME_TAG_SAFE-$BUILD_NUMBER - environment: - CI: - RETRIES: - REDIS_HOST: redis_test - QUEUES_REDIS_HOST: redis_test - HISTORY_REDIS_HOST: redis_test - ANALYTICS_QUEUES_REDIS_HOST: redis_test - ANALYTICS_QUEUES_BQ_SINK_REDIS_HOST: redis_test - MONGO_HOST: mongo - POSTGRES_HOST: postgres - AWS_S3_ENDPOINT: https://minio:9000 - AWS_S3_PATH_STYLE: "true" - DELETE_OBJECTS_MD5_FALLBACK: true - AWS_ACCESS_KEY_ID: OVERLEAF_HISTORY_S3_ACCESS_KEY_ID - AWS_SECRET_ACCESS_KEY: OVERLEAF_HISTORY_S3_SECRET_ACCESS_KEY - MINIO_ROOT_USER: MINIO_ROOT_USER - MINIO_ROOT_PASSWORD: MINIO_ROOT_PASSWORD - GCS_API_ENDPOINT: http://gcs:9090 - GCS_PROJECT_ID: fake - STORAGE_EMULATOR_HOST: http://gcs:9090/storage/v1 - MOCHA_GREP: ${MOCHA_GREP} - NODE_ENV: test - NODE_OPTIONS: "--unhandled-rejections=strict" - volumes: - - ./reports:/overleaf/services/history-v1/reports - - minio-certs:/certs - - ../../bin/shared/wait_for_it:/overleaf/bin/shared/wait_for_it - depends_on: - mongo: - condition: service_started - redis_test: - condition: service_healthy - postgres: - condition: service_healthy - certs: - condition: service_completed_successfully - minio: - condition: service_started - minio_setup: - condition: service_completed_successfully - gcs: - condition: service_healthy - user: node - entrypoint: /overleaf/bin/shared/wait_for_it mongo:27017 --timeout=60 -- - command: yarn run test:acceptance - - tar: - build: . - image: ci/$PROJECT_NAME:$BRANCH_NAME_TAG_SAFE-$BUILD_NUMBER - volumes: - - ./:/tmp/build/ - command: tar -czf /tmp/build/build.tar.gz --exclude=build.tar.gz --exclude-vcs . - user: root - - redis_test: - image: redis:7.4.8 - healthcheck: - test: ping="$$(redis-cli ping)" && [ "$$ping" = 'PONG' ] - interval: 1s - retries: 20 - - mongo: - image: mongo:8.0.11 - command: --replSet overleaf --notablescan - volumes: - - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js - - ../../bin/shared/mongodb-docker-entrypoint-wait.sh:/mongodb-docker-entrypoint-wait.sh - entrypoint: /mongodb-docker-entrypoint-wait.sh - environment: - MONGO_INITDB_DATABASE: sharelatex - extra_hosts: - # Required when using the automatic database setup for initializing the - # replica set. This override is not needed when running the setup after - # starting up mongo. - - mongo:127.0.0.1 - - postgres: - image: postgres:14 - environment: - POSTGRES_USER: overleaf - POSTGRES_PASSWORD: overleaf - POSTGRES_DB: overleaf-history-v1-test - volumes: - - ./test/acceptance/pg-init/:/docker-entrypoint-initdb.d/ - healthcheck: - test: pg_isready --quiet - interval: 1s - retries: 20 - - certs: - build: - dockerfile_inline: | - FROM node:24.14.1 - RUN wget -O /certgen "https://github.com/minio/certgen/releases/download/v1.3.0/certgen-linux-"`dpkg --print-architecture` - RUN chmod +x /certgen - volumes: - - minio-certs:/certs - working_dir: /certs - entrypoint: sh - command: - - "-cex" - - | - if [ ! -f private.key ] || [ ! -f public.crt ]; then - /certgen -host minio - fi - if ! openssl x509 -checkend 864000 -noout -in public.crt > /dev/null; then - /certgen -host minio - fi - - minio: - image: minio/minio:RELEASE.2024-10-13T13-34-11Z - command: server /data - volumes: - - minio-certs:/root/.minio/certs - environment: - MINIO_ROOT_USER: MINIO_ROOT_USER - MINIO_ROOT_PASSWORD: MINIO_ROOT_PASSWORD - depends_on: - certs: - condition: service_completed_successfully - - minio_setup: - depends_on: - certs: - condition: service_completed_successfully - minio: - condition: service_started - image: minio/mc:RELEASE.2024-10-08T09-37-26Z - volumes: - - minio-certs:/root/.mc/certs/CAs - entrypoint: sh - command: - - "-cex" - - | - sleep 1 - mc alias set s3 https://minio:9000 MINIO_ROOT_USER MINIO_ROOT_PASSWORD \ - || sleep 3 && \ - mc alias set s3 https://minio:9000 MINIO_ROOT_USER MINIO_ROOT_PASSWORD \ - || sleep 3 && \ - mc alias set s3 https://minio:9000 MINIO_ROOT_USER MINIO_ROOT_PASSWORD \ - || sleep 3 && \ - mc alias set s3 https://minio:9000 MINIO_ROOT_USER MINIO_ROOT_PASSWORD - mc mb --ignore-existing s3/overleaf-test-history-chunks - mc mb --ignore-existing s3/overleaf-test-history-deks - mc mb --ignore-existing s3/overleaf-test-history-global-blobs - mc mb --ignore-existing s3/overleaf-test-history-project-blobs - mc admin user add s3 \ - OVERLEAF_HISTORY_S3_ACCESS_KEY_ID \ - OVERLEAF_HISTORY_S3_SECRET_ACCESS_KEY - echo ' - { - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": [ - "s3:ListBucket" - ], - "Resource": "arn:aws:s3:::overleaf-test-history-chunks" - }, - { - "Effect": "Allow", - "Action": [ - "s3:PutObject", - "s3:GetObject", - "s3:DeleteObject", - "s3:AbortMultipartUpload", - "s3:ListMultipartUploadParts", - "s3:ListBucketMultipartUploads" - ], - "Resource": "arn:aws:s3:::overleaf-test-history-chunks/*" - }, - { - "Effect": "Allow", - "Action": [ - "s3:ListBucket" - ], - "Resource": "arn:aws:s3:::overleaf-test-history-deks" - }, - { - "Effect": "Allow", - "Action": [ - "s3:PutObject", - "s3:GetObject", - "s3:DeleteObject", - "s3:AbortMultipartUpload", - "s3:ListMultipartUploadParts", - "s3:ListBucketMultipartUploads" - ], - "Resource": "arn:aws:s3:::overleaf-test-history-deks/*" - }, - { - "Effect": "Allow", - "Action": [ - "s3:ListBucket" - ], - "Resource": "arn:aws:s3:::overleaf-test-history-global-blobs" - }, - { - "Effect": "Allow", - "Action": [ - "s3:PutObject", - "s3:GetObject", - "s3:DeleteObject", - "s3:AbortMultipartUpload", - "s3:ListMultipartUploadParts", - "s3:ListBucketMultipartUploads" - ], - "Resource": "arn:aws:s3:::overleaf-test-history-global-blobs/*" - }, - { - "Effect": "Allow", - "Action": [ - "s3:ListBucket" - ], - "Resource": "arn:aws:s3:::overleaf-test-history-project-blobs" - }, - { - "Effect": "Allow", - "Action": [ - "s3:PutObject", - "s3:GetObject", - "s3:DeleteObject", - "s3:AbortMultipartUpload", - "s3:ListMultipartUploadParts", - "s3:ListBucketMultipartUploads" - ], - "Resource": "arn:aws:s3:::overleaf-test-history-project-blobs/*" - } - ] - }' > policy-history.json - - mc admin policy create s3 overleaf-history policy-history.json - mc admin policy attach s3 overleaf-history \ - --user=OVERLEAF_HISTORY_S3_ACCESS_KEY_ID - - gcs: - image: fsouza/fake-gcs-server:1.52.3 - command: ["--port=9090", "--scheme=http", "--external-url=http://gcs:9090"] - healthcheck: - test: wget --quiet --output-document=/dev/null http://localhost:9090/storage/v1/b - interval: 1s - retries: 20 diff --git a/services/history-v1/docker-compose.yml b/services/history-v1/docker-compose.yml deleted file mode 100644 index a92ff491ba..0000000000 --- a/services/history-v1/docker-compose.yml +++ /dev/null @@ -1,292 +0,0 @@ -# This file was auto-generated, do not edit it directly. -# Instead run bin/update_build_scripts from -# https://github.com/overleaf/internal/ - -volumes: - minio-certs: - -services: - test_unit: - build: - context: ../.. - dockerfile: services/history-v1/Dockerfile - target: base - volumes: - - .:/overleaf/services/history-v1 - - ../../node_modules:/overleaf/node_modules - - ../../libraries:/overleaf/libraries - - ../../package.json:/overleaf/package.json - - ../../.yarnrc.yml:/overleaf/.yarnrc.yml - - ../../yarn.lock:/overleaf/yarn.lock - - ../../.yarn:/overleaf/.yarn - - ../../bin/shared/wait_for_it:/overleaf/bin/shared/wait_for_it - - ../../tools/migrations:/overleaf/tools/migrations - working_dir: /overleaf/services/history-v1 - environment: - MOCHA_GREP: ${MOCHA_GREP} - LOG_LEVEL: ${LOG_LEVEL:-} - MONGO_CONNECTION_STRING: mongodb://mongo/test-overleaf - NODE_ENV: test - NODE_OPTIONS: "--unhandled-rejections=strict" - REDIS_HOST: redis_test - HISTORY_REDIS_HOST: redis_test - QUEUES_REDIS_HOST: redis_test - ANALYTICS_QUEUES_REDIS_HOST: redis_test - ANALYTICS_QUEUES_BQ_SINK_REDIS_HOST: redis_test - entrypoint: /overleaf/bin/shared/wait_for_it mongo:27017 --timeout=60 -- - command: yarn run --silent test:unit - user: node - depends_on: - mongo: - condition: service_started - redis_test: - condition: service_healthy - - test_acceptance: - build: - context: ../.. - dockerfile: services/history-v1/Dockerfile - target: base - volumes: - - .:/overleaf/services/history-v1 - - ../../node_modules:/overleaf/node_modules - - ../../libraries:/overleaf/libraries - - ../../package.json:/overleaf/package.json - - ../../.yarnrc.yml:/overleaf/.yarnrc.yml - - ../../yarn.lock:/overleaf/yarn.lock - - ../../.yarn:/overleaf/.yarn - - minio-certs:/certs - - ../../bin/shared/wait_for_it:/overleaf/bin/shared/wait_for_it - - ../../tools/migrations:/overleaf/tools/migrations - working_dir: /overleaf/services/history-v1 - environment: - RETRIES: - REDIS_HOST: redis_test - HISTORY_REDIS_HOST: redis_test - QUEUES_REDIS_HOST: redis_test - ANALYTICS_QUEUES_REDIS_HOST: redis_test - ANALYTICS_QUEUES_BQ_SINK_REDIS_HOST: redis_test - MONGO_CONNECTION_STRING: mongodb://mongo/test-overleaf - POSTGRES_HOST: postgres - AWS_S3_ENDPOINT: https://minio:9000 - AWS_S3_PATH_STYLE: "true" - DELETE_OBJECTS_MD5_FALLBACK: true - AWS_ACCESS_KEY_ID: OVERLEAF_HISTORY_S3_ACCESS_KEY_ID - AWS_SECRET_ACCESS_KEY: OVERLEAF_HISTORY_S3_SECRET_ACCESS_KEY - MINIO_ROOT_USER: MINIO_ROOT_USER - MINIO_ROOT_PASSWORD: MINIO_ROOT_PASSWORD - GCS_API_ENDPOINT: http://gcs:9090 - GCS_PROJECT_ID: fake - STORAGE_EMULATOR_HOST: http://gcs:9090/storage/v1 - MOCHA_GREP: ${MOCHA_GREP} - LOG_LEVEL: ${LOG_LEVEL:-} - NODE_ENV: test - NODE_OPTIONS: "--unhandled-rejections=strict" - user: node - depends_on: - mongo: - condition: service_started - redis_test: - condition: service_healthy - postgres: - condition: service_healthy - certs: - condition: service_completed_successfully - minio: - condition: service_started - minio_setup: - condition: service_completed_successfully - gcs: - condition: service_healthy - entrypoint: /overleaf/bin/shared/wait_for_it mongo:27017 --timeout=60 -- - command: yarn run --silent test:acceptance - - redis_test: - image: redis:7.4.8 - healthcheck: - test: ping=$$(redis-cli ping) && [ "$$ping" = 'PONG' ] - interval: 1s - retries: 20 - - mongo: - image: mongo:8.0.11 - command: --replSet overleaf --notablescan - volumes: - - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js - - ../../bin/shared/mongodb-docker-entrypoint-wait.sh:/mongodb-docker-entrypoint-wait.sh - entrypoint: /mongodb-docker-entrypoint-wait.sh - environment: - MONGO_INITDB_DATABASE: sharelatex - extra_hosts: - # Required when using the automatic database setup for initializing the - # replica set. This override is not needed when running the setup after - # starting up mongo. - - mongo:127.0.0.1 - - postgres: - image: postgres:14 - environment: - POSTGRES_USER: overleaf - POSTGRES_PASSWORD: overleaf - POSTGRES_DB: overleaf-history-v1-test - volumes: - - ./test/acceptance/pg-init/:/docker-entrypoint-initdb.d/ - healthcheck: - test: pg_isready --host=localhost --quiet - interval: 1s - retries: 20 - - certs: - build: - dockerfile_inline: | - FROM node:24.14.1 - RUN wget -O /certgen "https://github.com/minio/certgen/releases/download/v1.3.0/certgen-linux-"`dpkg --print-architecture` - RUN chmod +x /certgen - volumes: - - minio-certs:/certs - working_dir: /certs - entrypoint: sh - command: - - "-cex" - - | - if [ ! -f private.key ] || [ ! -f public.crt ]; then - /certgen -host minio - fi - if ! openssl x509 -checkend 864000 -noout -in public.crt > /dev/null; then - /certgen -host minio - fi - - minio: - image: minio/minio:RELEASE.2024-10-13T13-34-11Z - command: server /data - volumes: - - minio-certs:/root/.minio/certs - environment: - MINIO_ROOT_USER: MINIO_ROOT_USER - MINIO_ROOT_PASSWORD: MINIO_ROOT_PASSWORD - depends_on: - certs: - condition: service_completed_successfully - - minio_setup: - depends_on: - certs: - condition: service_completed_successfully - minio: - condition: service_started - image: minio/mc:RELEASE.2024-10-08T09-37-26Z - volumes: - - minio-certs:/root/.mc/certs/CAs - entrypoint: sh - command: - - "-cex" - - | - sleep 1 - mc alias set s3 https://minio:9000 MINIO_ROOT_USER MINIO_ROOT_PASSWORD \ - || sleep 3 && \ - mc alias set s3 https://minio:9000 MINIO_ROOT_USER MINIO_ROOT_PASSWORD \ - || sleep 3 && \ - mc alias set s3 https://minio:9000 MINIO_ROOT_USER MINIO_ROOT_PASSWORD \ - || sleep 3 && \ - mc alias set s3 https://minio:9000 MINIO_ROOT_USER MINIO_ROOT_PASSWORD - mc mb --ignore-existing s3/overleaf-test-history-chunks - mc mb --ignore-existing s3/overleaf-test-history-deks - mc mb --ignore-existing s3/overleaf-test-history-global-blobs - mc mb --ignore-existing s3/overleaf-test-history-project-blobs - mc admin user add s3 \ - OVERLEAF_HISTORY_S3_ACCESS_KEY_ID \ - OVERLEAF_HISTORY_S3_SECRET_ACCESS_KEY - echo ' - { - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": [ - "s3:ListBucket" - ], - "Resource": "arn:aws:s3:::overleaf-test-history-chunks" - }, - { - "Effect": "Allow", - "Action": [ - "s3:PutObject", - "s3:GetObject", - "s3:DeleteObject", - "s3:AbortMultipartUpload", - "s3:ListMultipartUploadParts", - "s3:ListBucketMultipartUploads" - ], - "Resource": "arn:aws:s3:::overleaf-test-history-chunks/*" - }, - { - "Effect": "Allow", - "Action": [ - "s3:ListBucket" - ], - "Resource": "arn:aws:s3:::overleaf-test-history-deks" - }, - { - "Effect": "Allow", - "Action": [ - "s3:PutObject", - "s3:GetObject", - "s3:DeleteObject", - "s3:AbortMultipartUpload", - "s3:ListMultipartUploadParts", - "s3:ListBucketMultipartUploads" - ], - "Resource": "arn:aws:s3:::overleaf-test-history-deks/*" - }, - { - "Effect": "Allow", - "Action": [ - "s3:ListBucket" - ], - "Resource": "arn:aws:s3:::overleaf-test-history-global-blobs" - }, - { - "Effect": "Allow", - "Action": [ - "s3:PutObject", - "s3:GetObject", - "s3:DeleteObject", - "s3:AbortMultipartUpload", - "s3:ListMultipartUploadParts", - "s3:ListBucketMultipartUploads" - ], - "Resource": "arn:aws:s3:::overleaf-test-history-global-blobs/*" - }, - { - "Effect": "Allow", - "Action": [ - "s3:ListBucket" - ], - "Resource": "arn:aws:s3:::overleaf-test-history-project-blobs" - }, - { - "Effect": "Allow", - "Action": [ - "s3:PutObject", - "s3:GetObject", - "s3:DeleteObject", - "s3:AbortMultipartUpload", - "s3:ListMultipartUploadParts", - "s3:ListBucketMultipartUploads" - ], - "Resource": "arn:aws:s3:::overleaf-test-history-project-blobs/*" - } - ] - }' > policy-history.json - - mc admin policy create s3 overleaf-history policy-history.json - mc admin policy attach s3 overleaf-history \ - --user=OVERLEAF_HISTORY_S3_ACCESS_KEY_ID - - gcs: - image: fsouza/fake-gcs-server:1.52.3 - command: ["--port=9090", "--scheme=http", "--external-url=http://gcs:9090"] - healthcheck: - test: wget --quiet --output-document=/dev/null http://localhost:9090/storage/v1/b - interval: 1s - retries: 20 diff --git a/services/notifications/.nvmrc b/services/notifications/.nvmrc deleted file mode 100644 index 8e35034890..0000000000 --- a/services/notifications/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -24.14.1 diff --git a/services/notifications/Dockerfile b/services/notifications/Dockerfile deleted file mode 100644 index 0732cf7cc9..0000000000 --- a/services/notifications/Dockerfile +++ /dev/null @@ -1,53 +0,0 @@ -# This file was auto-generated, do not edit it directly. -# Instead run bin/update_build_scripts from -# https://github.com/overleaf/internal/ - -FROM node:24.14.1 AS base - -# Corepack setup, shared between all the images. -ENV PATH="/overleaf/node_modules/.bin:$PATH" -ENV COREPACK_HOME=/opt/corepack -RUN corepack enable && corepack install -g yarn@4.14.1 -ENV COREPACK_ENABLE_NETWORK=0 - -WORKDIR /overleaf/services/notifications - -# Google Cloud Storage needs a writable $HOME/.config for resumable uploads -# (see https://googleapis.dev/nodejs/storage/latest/File.html#createWriteStream) -RUN mkdir /home/node/.config && chown node:node /home/node/.config - -FROM base AS app - -COPY package.json yarn.lock .yarnrc.yml /overleaf/ -COPY libraries/eslint-plugin/package.json /overleaf/libraries/eslint-plugin/package.json -COPY libraries/fetch-utils/package.json /overleaf/libraries/fetch-utils/package.json -COPY libraries/logger/package.json /overleaf/libraries/logger/package.json -COPY libraries/metrics/package.json /overleaf/libraries/metrics/package.json -COPY libraries/mongo-utils/package.json /overleaf/libraries/mongo-utils/package.json -COPY libraries/o-error/package.json /overleaf/libraries/o-error/package.json -COPY libraries/promise-utils/package.json /overleaf/libraries/promise-utils/package.json -COPY libraries/settings/package.json /overleaf/libraries/settings/package.json -COPY libraries/validation-tools/package.json /overleaf/libraries/validation-tools/package.json -COPY services/notifications/package.json /overleaf/services/notifications/package.json -COPY tools/migrations/package.json /overleaf/tools/migrations/package.json -COPY .yarn/patches/ /overleaf/.yarn/patches/ -COPY tools/migrations/ /overleaf/tools/migrations/ - -RUN cd /overleaf && yarn workspaces focus @overleaf/notifications overleaf -COPY libraries/eslint-plugin/ /overleaf/libraries/eslint-plugin/ -COPY libraries/fetch-utils/ /overleaf/libraries/fetch-utils/ -COPY libraries/logger/ /overleaf/libraries/logger/ -COPY libraries/metrics/ /overleaf/libraries/metrics/ -COPY libraries/mongo-utils/ /overleaf/libraries/mongo-utils/ -COPY libraries/o-error/ /overleaf/libraries/o-error/ -COPY libraries/promise-utils/ /overleaf/libraries/promise-utils/ -COPY libraries/settings/ /overleaf/libraries/settings/ -COPY libraries/validation-tools/ /overleaf/libraries/validation-tools/ -COPY services/notifications/ /overleaf/services/notifications/ -COPY tools/migrations/ /overleaf/tools/migrations/ - -FROM app -USER node - -CMD ["node", "--expose-gc", "app.ts"] - diff --git a/services/notifications/Makefile b/services/notifications/Makefile deleted file mode 100644 index 74236ad99a..0000000000 --- a/services/notifications/Makefile +++ /dev/null @@ -1,206 +0,0 @@ -# This file was auto-generated, do not edit it directly. -# Instead run bin/update_build_scripts from -# https://github.com/overleaf/internal/ - -export BUILD_NUMBER ?= local -export BRANCH_NAME ?= $(shell git rev-parse --abbrev-ref HEAD) -export BRANCH_NAME_TAG_SAFE = $(shell echo $(BRANCH_NAME) | sed 's/\//\-\-/g' | cut -c1-96) -export PROJECT_NAME = notifications -BUILD_DIR_NAME = $(shell pwd | xargs basename | tr -cd '[a-zA-Z0-9_.\-]') -HERE=$(shell pwd) -export MONOREPO ?= $(shell cd ../../ && pwd) -export RETRIES ?= $(if $(CI),3,0) -IMAGE_CI ?= ci/$(PROJECT_NAME):$(BRANCH_NAME_TAG_SAFE)-$(BUILD_NUMBER) -IMAGE_REPO ?= us-east1-docker.pkg.dev/overleaf-ops/ol-docker/$(PROJECT_NAME) -IMAGE_REPO_FINAL ?= $(IMAGE_REPO):$(BRANCH_NAME_TAG_SAFE)-$(BUILD_NUMBER) -IMAGE_CACHE ?= $(IMAGE_REPO):cache-$(shell cat \ - $(MONOREPO)/package.json \ - $(MONOREPO)/yarn.lock \ - $(MONOREPO)/libraries/eslint-plugin/package.json \ - $(MONOREPO)/libraries/fetch-utils/package.json \ - $(MONOREPO)/libraries/logger/package.json \ - $(MONOREPO)/libraries/metrics/package.json \ - $(MONOREPO)/libraries/mongo-utils/package.json \ - $(MONOREPO)/libraries/o-error/package.json \ - $(MONOREPO)/libraries/promise-utils/package.json \ - $(MONOREPO)/libraries/settings/package.json \ - $(MONOREPO)/libraries/validation-tools/package.json \ - $(MONOREPO)/services/notifications/package.json \ - $(MONOREPO)/tools/migrations/package.json \ - $(MONOREPO)/.yarn/patches/* \ -| sha256sum | cut -d '-' -f1) -export MOCHA_GREP ?= - -DOCKER_COMPOSE_FLAGS ?= -f docker-compose.yml -DOCKER_COMPOSE := docker compose ${DOCKER_COMPOSE_FLAGS} - -# Common flags for docker compose run. -# --rm Tear down test runner container -# --build Make sure that we use the latest base image -# --quiet-build Hide build output -ifeq ($(CI),true) - DC_RUN_FLAGS := --rm -else - DC_RUN_FLAGS := --rm --build --quiet-build -endif - -COMPOSE_PROJECT_NAME_TEST_ACCEPTANCE ?= test_acceptance_$(BUILD_DIR_NAME) -DOCKER_COMPOSE_TEST_ACCEPTANCE = \ - COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME_TEST_ACCEPTANCE) $(DOCKER_COMPOSE) - -COMPOSE_PROJECT_NAME_TEST_UNIT ?= test_unit_$(BUILD_DIR_NAME) -DOCKER_COMPOSE_TEST_UNIT = \ - COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME_TEST_UNIT) $(DOCKER_COMPOSE) - -.PHONY: print-branch-tag-safe -print-branch-tag-safe: - @echo $(BRANCH_NAME_TAG_SAFE) - -clean: - -docker rmi $(IMAGE_CI) - -docker rmi $(IMAGE_REPO_FINAL) - -$(DOCKER_COMPOSE_TEST_UNIT) down --remove-orphans --rmi local --timeout 0 --volumes - -$(DOCKER_COMPOSE_TEST_ACCEPTANCE) down --remove-orphans --rmi local --timeout 0 --volumes - -rm -rf reports/ - -# Run the linting commands in the scope of the monorepo. -# Eslint and prettier (plus some configs) are on the root. -RUN_LINTING = ../../bin/run -w /overleaf/services/$(PROJECT_NAME) monorepo yarn run --silent -RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent - -RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/notifications/reports:/overleaf/services/notifications/reports --volume $(MONOREPO)/.cache:/overleaf/.cache $(IMAGE_CI) yarn run --silent -RUN_LINTING_CI_MONOREPO = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/notifications/reports:/overleaf/services/notifications/reports --volume $(MONOREPO)/.cache:/overleaf/.cache -w /overleaf $(IMAGE_CI) yarn run --silent - -SHELLCHECK_OPTS = \ - --shell=bash \ - --external-sources -SHELLCHECK_COLOR := $(if $(CI),--color=never,--color) -SHELLCHECK_FILES := { git ls-files "*.sh" -z; git grep -Plz "\A\#\!.*bash"; } | sort -zu - -shellcheck: - @$(SHELLCHECK_FILES) | xargs -0 -r docker run --rm -v $(HERE):/mnt -w /mnt \ - koalaman/shellcheck:stable $(SHELLCHECK_OPTS) $(SHELLCHECK_COLOR) - -shellcheck_fix: - @$(SHELLCHECK_FILES) | while IFS= read -r -d '' file; do \ - diff=$$(docker run --rm -v $(HERE):/mnt -w /mnt koalaman/shellcheck:stable $(SHELLCHECK_OPTS) --format=diff "$$file" 2>/dev/null); \ - if [ -n "$$diff" ] && ! echo "$$diff" | patch -p1 >/dev/null 2>&1; then echo "\033[31m$$file\033[0m"; \ - elif [ -n "$$diff" ]; then echo "$$file"; \ - else echo "\033[2m$$file\033[0m"; fi \ - done - -WITH_CACHE_FOLDER = \ - lint \ - lint_ci \ - lint_fix \ - format \ - format_ci \ - format_fix \ - typecheck \ - typecheck_ci \ - -$(WITH_CACHE_FOLDER): ../../.cache/prettier/ -../../.cache/prettier/: - mkdir -p $@ - -$(WITH_CACHE_FOLDER): ../../.cache/eslint/ -../../.cache/eslint/: - mkdir -p $@ - -format: - $(RUN_LINTING_MONOREPO) format services/notifications - -format_ci: - $(RUN_LINTING_CI_MONOREPO) format services/notifications - -format_fix: - $(RUN_LINTING_MONOREPO) format:fix services/notifications - -lint: - $(RUN_LINTING) lint - -lint_ci: - -$(RUN_LINTING_CI) lint --format json --output-file reports/eslint.json - sed -i 's_"filePath":"/overleaf_"filePath":"$(MONOREPO)_g' reports/eslint.json - -lint_fix: - $(RUN_LINTING) lint:fix - -typecheck: - $(RUN_LINTING) types:check - -typecheck_ci: - $(RUN_LINTING_CI) types:check - -test: format lint typecheck shellcheck test_unit test_acceptance - -test_unit: -ifneq (,$(wildcard test/unit)) - $(DOCKER_COMPOSE_TEST_UNIT) run $(DC_RUN_FLAGS) test_unit -endif - -test_clean: test_unit_clean -test_unit_clean: -ifneq (,$(wildcard test/unit)) - $(DOCKER_COMPOSE_TEST_UNIT) down -v -t 0 -endif - -test_acceptance: test_acceptance_clean test_acceptance_pre_run test_acceptance_run - $(MAKE) test_acceptance_clean - -test_acceptance_debug: test_acceptance_clean test_acceptance_pre_run test_acceptance_run_debug - $(MAKE) test_acceptance_clean - -test_acceptance_run: -ifneq (,$(wildcard test/acceptance)) - $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run $(DC_RUN_FLAGS) test_acceptance -endif - -test_acceptance_run_debug: -ifneq (,$(wildcard test/acceptance)) - $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run -p 127.0.0.9:19999:19999 $(DC_RUN_FLAGS) test_acceptance yarn run --inspect=0.0.0.0:19999 --inspect-brk test:acceptance -endif - -test_clean: test_acceptance_clean -test_acceptance_clean: - $(DOCKER_COMPOSE_TEST_ACCEPTANCE) down -v -t 0 - -test_acceptance_pre_run: -ifneq (,$(wildcard test/acceptance/js/scripts/pre-run)) - $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run $(DC_RUN_FLAGS) test_acceptance test/acceptance/js/scripts/pre-run -endif - -benchmarks: - $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run $(DC_RUN_FLAGS) test_acceptance yarn run benchmarks - -build: - docker build \ - --pull \ - --build-arg BUILDKIT_INLINE_CACHE=1 \ - --tag $(IMAGE_CI) \ - --tag $(IMAGE_CACHE) \ - --tag $(IMAGE_REPO_FINAL) \ - --cache-from $(IMAGE_CACHE) \ - --file Dockerfile \ - ../.. - -tar: - $(DOCKER_COMPOSE) up tar - -push: - docker push $(IMAGE_REPO_FINAL) - -push_branch: - docker push $(IMAGE_CACHE) - -.PHONY: clean \ - format format_fix \ - lint lint_fix \ - build_types typecheck \ - lint_ci format_ci typecheck_ci \ - shellcheck shellcheck_fix \ - test test_clean test_unit test_unit_clean \ - test_acceptance test_acceptance_debug test_acceptance_pre_run \ - test_acceptance_run test_acceptance_run_debug test_acceptance_clean \ - benchmarks \ - build tar publish \ diff --git a/services/notifications/buildscript.txt b/services/notifications/buildscript.txt deleted file mode 100644 index e15499cc5f..0000000000 --- a/services/notifications/buildscript.txt +++ /dev/null @@ -1,13 +0,0 @@ -notifications ---dependencies=mongo ---deploy-pipeline=notifications ---env-add= ---env-pass-through= ---esmock-loader=False ---node-version=24.14.1 ---pipeline-owner=32 ---public-repo=True ---test-acceptance-vitest=True ---test-unit-vitest=True ---ts-app=True ---tsconfig-extra-includes=vitest.config.unit.cjs,vitest.config.acceptance.cjs diff --git a/services/notifications/docker-compose.ci.yml b/services/notifications/docker-compose.ci.yml deleted file mode 100644 index 79931efb57..0000000000 --- a/services/notifications/docker-compose.ci.yml +++ /dev/null @@ -1,67 +0,0 @@ -# This file was auto-generated, do not edit it directly. -# Instead run bin/update_build_scripts from -# https://github.com/overleaf/internal/ - -services: - test_unit: - image: ci/$PROJECT_NAME:$BRANCH_NAME_TAG_SAFE-$BUILD_NUMBER - user: node - volumes: - - ./reports:/overleaf/services/notifications/reports - - ../../bin/shared/wait_for_it:/overleaf/bin/shared/wait_for_it - - ../../tsconfig.backend.json:/overleaf/tsconfig.backend.json - entrypoint: /overleaf/bin/shared/wait_for_it mongo:27017 --timeout=60 -- - command: yarn run test:unit:_run - environment: - CI: - MONGO_CONNECTION_STRING: mongodb://mongo/test-overleaf - NODE_ENV: test - NODE_OPTIONS: "--unhandled-rejections=strict" - VITEST_NO_CACHE: true - depends_on: - mongo: - condition: service_started - - test_acceptance: - image: ci/$PROJECT_NAME:$BRANCH_NAME_TAG_SAFE-$BUILD_NUMBER - environment: - CI: - RETRIES: - MONGO_HOST: mongo - POSTGRES_HOST: postgres - MOCHA_GREP: ${MOCHA_GREP} - NODE_ENV: test - NODE_OPTIONS: "--unhandled-rejections=strict" - volumes: - - ./reports:/overleaf/services/notifications/reports - - ../../bin/shared/wait_for_it:/overleaf/bin/shared/wait_for_it - - ../../tsconfig.backend.json:/overleaf/tsconfig.backend.json - depends_on: - mongo: - condition: service_started - user: node - entrypoint: /overleaf/bin/shared/wait_for_it mongo:27017 --timeout=60 -- - command: yarn run test:acceptance - - tar: - build: . - image: ci/$PROJECT_NAME:$BRANCH_NAME_TAG_SAFE-$BUILD_NUMBER - volumes: - - ./:/tmp/build/ - command: tar -czf /tmp/build/build.tar.gz --exclude=build.tar.gz --exclude-vcs . - user: root - - mongo: - image: mongo:8.0.11 - command: --replSet overleaf --notablescan - volumes: - - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js - - ../../bin/shared/mongodb-docker-entrypoint-wait.sh:/mongodb-docker-entrypoint-wait.sh - entrypoint: /mongodb-docker-entrypoint-wait.sh - environment: - MONGO_INITDB_DATABASE: sharelatex - extra_hosts: - # Required when using the automatic database setup for initializing the - # replica set. This override is not needed when running the setup after - # starting up mongo. - - mongo:127.0.0.1 diff --git a/services/notifications/docker-compose.yml b/services/notifications/docker-compose.yml deleted file mode 100644 index 0361fea5fc..0000000000 --- a/services/notifications/docker-compose.yml +++ /dev/null @@ -1,81 +0,0 @@ -# This file was auto-generated, do not edit it directly. -# Instead run bin/update_build_scripts from -# https://github.com/overleaf/internal/ - -services: - test_unit: - build: - context: ../.. - dockerfile: services/notifications/Dockerfile - target: base - volumes: - - .:/overleaf/services/notifications - - ../../node_modules:/overleaf/node_modules - - ../../libraries:/overleaf/libraries - - ../../package.json:/overleaf/package.json - - ../../.yarnrc.yml:/overleaf/.yarnrc.yml - - ../../yarn.lock:/overleaf/yarn.lock - - ../../.yarn:/overleaf/.yarn - - ../../bin/shared/wait_for_it:/overleaf/bin/shared/wait_for_it - - ../../tools/migrations:/overleaf/tools/migrations - - ../../tsconfig.backend.json:/overleaf/tsconfig.backend.json - working_dir: /overleaf/services/notifications - environment: - MOCHA_GREP: ${MOCHA_GREP} - LOG_LEVEL: ${LOG_LEVEL:-} - MONGO_CONNECTION_STRING: mongodb://mongo/test-overleaf - NODE_ENV: test - NODE_OPTIONS: "--unhandled-rejections=strict" - entrypoint: /overleaf/bin/shared/wait_for_it mongo:27017 --timeout=60 -- - command: yarn run --silent test:unit - user: node - depends_on: - mongo: - condition: service_started - - test_acceptance: - build: - context: ../.. - dockerfile: services/notifications/Dockerfile - target: base - volumes: - - .:/overleaf/services/notifications - - ../../node_modules:/overleaf/node_modules - - ../../libraries:/overleaf/libraries - - ../../package.json:/overleaf/package.json - - ../../.yarnrc.yml:/overleaf/.yarnrc.yml - - ../../yarn.lock:/overleaf/yarn.lock - - ../../.yarn:/overleaf/.yarn - - ../../bin/shared/wait_for_it:/overleaf/bin/shared/wait_for_it - - ../../tools/migrations:/overleaf/tools/migrations - - ../../tsconfig.backend.json:/overleaf/tsconfig.backend.json - working_dir: /overleaf/services/notifications - environment: - RETRIES: - MONGO_CONNECTION_STRING: mongodb://mongo/test-overleaf - POSTGRES_HOST: postgres - MOCHA_GREP: ${MOCHA_GREP} - LOG_LEVEL: ${LOG_LEVEL:-} - NODE_ENV: test - NODE_OPTIONS: "--unhandled-rejections=strict" - user: node - depends_on: - mongo: - condition: service_started - entrypoint: /overleaf/bin/shared/wait_for_it mongo:27017 --timeout=60 -- - command: yarn run --silent test:acceptance - - mongo: - image: mongo:8.0.11 - command: --replSet overleaf --notablescan - volumes: - - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js - - ../../bin/shared/mongodb-docker-entrypoint-wait.sh:/mongodb-docker-entrypoint-wait.sh - entrypoint: /mongodb-docker-entrypoint-wait.sh - environment: - MONGO_INITDB_DATABASE: sharelatex - extra_hosts: - # Required when using the automatic database setup for initializing the - # replica set. This override is not needed when running the setup after - # starting up mongo. - - mongo:127.0.0.1 diff --git a/services/project-history/.nvmrc b/services/project-history/.nvmrc deleted file mode 100644 index 8e35034890..0000000000 --- a/services/project-history/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -24.14.1 diff --git a/services/project-history/Dockerfile b/services/project-history/Dockerfile deleted file mode 100644 index 2bab7e0eb9..0000000000 --- a/services/project-history/Dockerfile +++ /dev/null @@ -1,58 +0,0 @@ -# This file was auto-generated, do not edit it directly. -# Instead run bin/update_build_scripts from -# https://github.com/overleaf/internal/ - -FROM node:24.14.1 AS base - -# Corepack setup, shared between all the images. -ENV PATH="/overleaf/node_modules/.bin:$PATH" -ENV COREPACK_HOME=/opt/corepack -RUN corepack enable && corepack install -g yarn@4.14.1 -ENV COREPACK_ENABLE_NETWORK=0 - -WORKDIR /overleaf/services/project-history - -# Google Cloud Storage needs a writable $HOME/.config for resumable uploads -# (see https://googleapis.dev/nodejs/storage/latest/File.html#createWriteStream) -RUN mkdir /home/node/.config && chown node:node /home/node/.config - -FROM base AS app - -COPY package.json yarn.lock .yarnrc.yml /overleaf/ -COPY libraries/eslint-plugin/package.json /overleaf/libraries/eslint-plugin/package.json -COPY libraries/fetch-utils/package.json /overleaf/libraries/fetch-utils/package.json -COPY libraries/logger/package.json /overleaf/libraries/logger/package.json -COPY libraries/metrics/package.json /overleaf/libraries/metrics/package.json -COPY libraries/mongo-utils/package.json /overleaf/libraries/mongo-utils/package.json -COPY libraries/o-error/package.json /overleaf/libraries/o-error/package.json -COPY libraries/overleaf-editor-core/package.json /overleaf/libraries/overleaf-editor-core/package.json -COPY libraries/promise-utils/package.json /overleaf/libraries/promise-utils/package.json -COPY libraries/redis-wrapper/package.json /overleaf/libraries/redis-wrapper/package.json -COPY libraries/settings/package.json /overleaf/libraries/settings/package.json -COPY libraries/stream-utils/package.json /overleaf/libraries/stream-utils/package.json -COPY libraries/validation-tools/package.json /overleaf/libraries/validation-tools/package.json -COPY services/project-history/package.json /overleaf/services/project-history/package.json -COPY tools/migrations/package.json /overleaf/tools/migrations/package.json -COPY .yarn/patches/ /overleaf/.yarn/patches/ -COPY tools/migrations/ /overleaf/tools/migrations/ - -RUN cd /overleaf && yarn workspaces focus @overleaf/project-history overleaf -COPY libraries/eslint-plugin/ /overleaf/libraries/eslint-plugin/ -COPY libraries/fetch-utils/ /overleaf/libraries/fetch-utils/ -COPY libraries/logger/ /overleaf/libraries/logger/ -COPY libraries/metrics/ /overleaf/libraries/metrics/ -COPY libraries/mongo-utils/ /overleaf/libraries/mongo-utils/ -COPY libraries/o-error/ /overleaf/libraries/o-error/ -COPY libraries/overleaf-editor-core/ /overleaf/libraries/overleaf-editor-core/ -COPY libraries/promise-utils/ /overleaf/libraries/promise-utils/ -COPY libraries/redis-wrapper/ /overleaf/libraries/redis-wrapper/ -COPY libraries/settings/ /overleaf/libraries/settings/ -COPY libraries/stream-utils/ /overleaf/libraries/stream-utils/ -COPY libraries/validation-tools/ /overleaf/libraries/validation-tools/ -COPY services/project-history/ /overleaf/services/project-history/ -COPY tools/migrations/ /overleaf/tools/migrations/ - -FROM app -USER node - -CMD ["node", "--expose-gc", "app.js"] diff --git a/services/project-history/Makefile b/services/project-history/Makefile deleted file mode 100644 index 732aba5bad..0000000000 --- a/services/project-history/Makefile +++ /dev/null @@ -1,209 +0,0 @@ -# This file was auto-generated, do not edit it directly. -# Instead run bin/update_build_scripts from -# https://github.com/overleaf/internal/ - -export BUILD_NUMBER ?= local -export BRANCH_NAME ?= $(shell git rev-parse --abbrev-ref HEAD) -export BRANCH_NAME_TAG_SAFE = $(shell echo $(BRANCH_NAME) | sed 's/\//\-\-/g' | cut -c1-96) -export PROJECT_NAME = project-history -BUILD_DIR_NAME = $(shell pwd | xargs basename | tr -cd '[a-zA-Z0-9_.\-]') -HERE=$(shell pwd) -export MONOREPO ?= $(shell cd ../../ && pwd) -export RETRIES ?= $(if $(CI),3,0) -IMAGE_CI ?= ci/$(PROJECT_NAME):$(BRANCH_NAME_TAG_SAFE)-$(BUILD_NUMBER) -IMAGE_REPO ?= us-east1-docker.pkg.dev/overleaf-ops/ol-docker/$(PROJECT_NAME) -IMAGE_REPO_FINAL ?= $(IMAGE_REPO):$(BRANCH_NAME_TAG_SAFE)-$(BUILD_NUMBER) -IMAGE_CACHE ?= $(IMAGE_REPO):cache-$(shell cat \ - $(MONOREPO)/package.json \ - $(MONOREPO)/yarn.lock \ - $(MONOREPO)/libraries/eslint-plugin/package.json \ - $(MONOREPO)/libraries/fetch-utils/package.json \ - $(MONOREPO)/libraries/logger/package.json \ - $(MONOREPO)/libraries/metrics/package.json \ - $(MONOREPO)/libraries/mongo-utils/package.json \ - $(MONOREPO)/libraries/o-error/package.json \ - $(MONOREPO)/libraries/overleaf-editor-core/package.json \ - $(MONOREPO)/libraries/promise-utils/package.json \ - $(MONOREPO)/libraries/redis-wrapper/package.json \ - $(MONOREPO)/libraries/settings/package.json \ - $(MONOREPO)/libraries/stream-utils/package.json \ - $(MONOREPO)/libraries/validation-tools/package.json \ - $(MONOREPO)/services/project-history/package.json \ - $(MONOREPO)/tools/migrations/package.json \ - $(MONOREPO)/.yarn/patches/* \ -| sha256sum | cut -d '-' -f1) -export MOCHA_GREP ?= - -DOCKER_COMPOSE_FLAGS ?= -f docker-compose.yml -DOCKER_COMPOSE := docker compose ${DOCKER_COMPOSE_FLAGS} - -# Common flags for docker compose run. -# --rm Tear down test runner container -# --build Make sure that we use the latest base image -# --quiet-build Hide build output -ifeq ($(CI),true) - DC_RUN_FLAGS := --rm -else - DC_RUN_FLAGS := --rm --build --quiet-build -endif - -COMPOSE_PROJECT_NAME_TEST_ACCEPTANCE ?= test_acceptance_$(BUILD_DIR_NAME) -DOCKER_COMPOSE_TEST_ACCEPTANCE = \ - COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME_TEST_ACCEPTANCE) $(DOCKER_COMPOSE) - -COMPOSE_PROJECT_NAME_TEST_UNIT ?= test_unit_$(BUILD_DIR_NAME) -DOCKER_COMPOSE_TEST_UNIT = \ - COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME_TEST_UNIT) $(DOCKER_COMPOSE) - -.PHONY: print-branch-tag-safe -print-branch-tag-safe: - @echo $(BRANCH_NAME_TAG_SAFE) - -clean: - -docker rmi $(IMAGE_CI) - -docker rmi $(IMAGE_REPO_FINAL) - -$(DOCKER_COMPOSE_TEST_UNIT) down --remove-orphans --rmi local --timeout 0 --volumes - -$(DOCKER_COMPOSE_TEST_ACCEPTANCE) down --remove-orphans --rmi local --timeout 0 --volumes - -rm -rf reports/ - -# Run the linting commands in the scope of the monorepo. -# Eslint and prettier (plus some configs) are on the root. -RUN_LINTING = ../../bin/run -w /overleaf/services/$(PROJECT_NAME) monorepo yarn run --silent -RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent - -RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/document-updater/app/js/types.ts:/overleaf/services/document-updater/app/js/types.ts --volume $(MONOREPO)/services/project-history/reports:/overleaf/services/project-history/reports --volume $(MONOREPO)/.cache:/overleaf/.cache $(IMAGE_CI) yarn run --silent -RUN_LINTING_CI_MONOREPO = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/document-updater/app/js/types.ts:/overleaf/services/document-updater/app/js/types.ts --volume $(MONOREPO)/services/project-history/reports:/overleaf/services/project-history/reports --volume $(MONOREPO)/.cache:/overleaf/.cache -w /overleaf $(IMAGE_CI) yarn run --silent - -SHELLCHECK_OPTS = \ - --shell=bash \ - --external-sources -SHELLCHECK_COLOR := $(if $(CI),--color=never,--color) -SHELLCHECK_FILES := { git ls-files "*.sh" -z; git grep -Plz "\A\#\!.*bash"; } | sort -zu - -shellcheck: - @$(SHELLCHECK_FILES) | xargs -0 -r docker run --rm -v $(HERE):/mnt -w /mnt \ - koalaman/shellcheck:stable $(SHELLCHECK_OPTS) $(SHELLCHECK_COLOR) - -shellcheck_fix: - @$(SHELLCHECK_FILES) | while IFS= read -r -d '' file; do \ - diff=$$(docker run --rm -v $(HERE):/mnt -w /mnt koalaman/shellcheck:stable $(SHELLCHECK_OPTS) --format=diff "$$file" 2>/dev/null); \ - if [ -n "$$diff" ] && ! echo "$$diff" | patch -p1 >/dev/null 2>&1; then echo "\033[31m$$file\033[0m"; \ - elif [ -n "$$diff" ]; then echo "$$file"; \ - else echo "\033[2m$$file\033[0m"; fi \ - done - -WITH_CACHE_FOLDER = \ - lint \ - lint_ci \ - lint_fix \ - format \ - format_ci \ - format_fix \ - typecheck \ - typecheck_ci \ - -$(WITH_CACHE_FOLDER): ../../.cache/prettier/ -../../.cache/prettier/: - mkdir -p $@ - -$(WITH_CACHE_FOLDER): ../../.cache/eslint/ -../../.cache/eslint/: - mkdir -p $@ - -format: - $(RUN_LINTING_MONOREPO) format services/project-history - -format_ci: - $(RUN_LINTING_CI_MONOREPO) format services/project-history - -format_fix: - $(RUN_LINTING_MONOREPO) format:fix services/project-history - -lint: - $(RUN_LINTING) lint - -lint_ci: - -$(RUN_LINTING_CI) lint --format json --output-file reports/eslint.json - sed -i 's_"filePath":"/overleaf_"filePath":"$(MONOREPO)_g' reports/eslint.json - -lint_fix: - $(RUN_LINTING) lint:fix - -typecheck: - $(RUN_LINTING) types:check - -typecheck_ci: - $(RUN_LINTING_CI) types:check - -test: format lint typecheck shellcheck test_unit test_acceptance - -test_unit: -ifneq (,$(wildcard test/unit)) - $(DOCKER_COMPOSE_TEST_UNIT) run $(DC_RUN_FLAGS) test_unit -endif - -test_clean: test_unit_clean -test_unit_clean: -ifneq (,$(wildcard test/unit)) - $(DOCKER_COMPOSE_TEST_UNIT) down -v -t 0 -endif - -test_acceptance: test_acceptance_clean test_acceptance_pre_run test_acceptance_run - $(MAKE) test_acceptance_clean - -test_acceptance_debug: test_acceptance_clean test_acceptance_pre_run test_acceptance_run_debug - $(MAKE) test_acceptance_clean - -test_acceptance_run: -ifneq (,$(wildcard test/acceptance)) - $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run $(DC_RUN_FLAGS) test_acceptance -endif - -test_acceptance_run_debug: -ifneq (,$(wildcard test/acceptance)) - $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run -p 127.0.0.9:19999:19999 $(DC_RUN_FLAGS) test_acceptance yarn run --inspect=0.0.0.0:19999 --inspect-brk test:acceptance -endif - -test_clean: test_acceptance_clean -test_acceptance_clean: - $(DOCKER_COMPOSE_TEST_ACCEPTANCE) down -v -t 0 - -test_acceptance_pre_run: -ifneq (,$(wildcard test/acceptance/js/scripts/pre-run)) - $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run $(DC_RUN_FLAGS) test_acceptance test/acceptance/js/scripts/pre-run -endif - -benchmarks: - $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run $(DC_RUN_FLAGS) test_acceptance yarn run benchmarks - -build: - docker build \ - --pull \ - --build-arg BUILDKIT_INLINE_CACHE=1 \ - --tag $(IMAGE_CI) \ - --tag $(IMAGE_CACHE) \ - --tag $(IMAGE_REPO_FINAL) \ - --cache-from $(IMAGE_CACHE) \ - --file Dockerfile \ - ../.. - -tar: - $(DOCKER_COMPOSE) up tar - -push: - docker push $(IMAGE_REPO_FINAL) - -push_branch: - docker push $(IMAGE_CACHE) - -.PHONY: clean \ - format format_fix \ - lint lint_fix \ - build_types typecheck \ - lint_ci format_ci typecheck_ci \ - shellcheck shellcheck_fix \ - test test_clean test_unit test_unit_clean \ - test_acceptance test_acceptance_debug test_acceptance_pre_run \ - test_acceptance_run test_acceptance_run_debug test_acceptance_clean \ - benchmarks \ - build tar publish \ diff --git a/services/project-history/buildscript.txt b/services/project-history/buildscript.txt deleted file mode 100644 index 5115a0c8d8..0000000000 --- a/services/project-history/buildscript.txt +++ /dev/null @@ -1,9 +0,0 @@ -project-history ---dependencies=mongo,redis ---deploy-pipeline=project-history ---env-add= ---env-pass-through= ---esmock-loader=True ---node-version=24.14.1 ---pipeline-owner=44 ---public-repo=False diff --git a/services/project-history/docker-compose.ci.yml b/services/project-history/docker-compose.ci.yml deleted file mode 100644 index 24231a3a61..0000000000 --- a/services/project-history/docker-compose.ci.yml +++ /dev/null @@ -1,85 +0,0 @@ -# This file was auto-generated, do not edit it directly. -# Instead run bin/update_build_scripts from -# https://github.com/overleaf/internal/ - -services: - test_unit: - image: ci/$PROJECT_NAME:$BRANCH_NAME_TAG_SAFE-$BUILD_NUMBER - user: node - volumes: - - ./reports:/overleaf/services/project-history/reports - - ../../bin/shared/wait_for_it:/overleaf/bin/shared/wait_for_it - entrypoint: /overleaf/bin/shared/wait_for_it mongo:27017 --timeout=60 -- - command: yarn run test:unit:_run - environment: - CI: - MONGO_CONNECTION_STRING: mongodb://mongo/test-overleaf - NODE_ENV: test - NODE_OPTIONS: "--unhandled-rejections=strict" - REDIS_HOST: redis_test - QUEUES_REDIS_HOST: redis_test - HISTORY_REDIS_HOST: redis_test - ANALYTICS_QUEUES_REDIS_HOST: redis_test - ANALYTICS_QUEUES_BQ_SINK_REDIS_HOST: redis_test - depends_on: - mongo: - condition: service_started - redis_test: - condition: service_healthy - - test_acceptance: - image: ci/$PROJECT_NAME:$BRANCH_NAME_TAG_SAFE-$BUILD_NUMBER - environment: - CI: - RETRIES: - REDIS_HOST: redis_test - QUEUES_REDIS_HOST: redis_test - HISTORY_REDIS_HOST: redis_test - ANALYTICS_QUEUES_REDIS_HOST: redis_test - ANALYTICS_QUEUES_BQ_SINK_REDIS_HOST: redis_test - MONGO_HOST: mongo - POSTGRES_HOST: postgres - MOCHA_GREP: ${MOCHA_GREP} - NODE_ENV: test - NODE_OPTIONS: "--unhandled-rejections=strict" - volumes: - - ./reports:/overleaf/services/project-history/reports - - ../../bin/shared/wait_for_it:/overleaf/bin/shared/wait_for_it - depends_on: - mongo: - condition: service_started - redis_test: - condition: service_healthy - user: node - entrypoint: /overleaf/bin/shared/wait_for_it mongo:27017 --timeout=60 -- - command: yarn run test:acceptance - - tar: - build: . - image: ci/$PROJECT_NAME:$BRANCH_NAME_TAG_SAFE-$BUILD_NUMBER - volumes: - - ./:/tmp/build/ - command: tar -czf /tmp/build/build.tar.gz --exclude=build.tar.gz --exclude-vcs . - user: root - - redis_test: - image: redis:7.4.8 - healthcheck: - test: ping="$$(redis-cli ping)" && [ "$$ping" = 'PONG' ] - interval: 1s - retries: 20 - - mongo: - image: mongo:8.0.11 - command: --replSet overleaf --notablescan - volumes: - - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js - - ../../bin/shared/mongodb-docker-entrypoint-wait.sh:/mongodb-docker-entrypoint-wait.sh - entrypoint: /mongodb-docker-entrypoint-wait.sh - environment: - MONGO_INITDB_DATABASE: sharelatex - extra_hosts: - # Required when using the automatic database setup for initializing the - # replica set. This override is not needed when running the setup after - # starting up mongo. - - mongo:127.0.0.1 diff --git a/services/project-history/docker-compose.yml b/services/project-history/docker-compose.yml deleted file mode 100644 index 38f0a353d5..0000000000 --- a/services/project-history/docker-compose.yml +++ /dev/null @@ -1,100 +0,0 @@ -# This file was auto-generated, do not edit it directly. -# Instead run bin/update_build_scripts from -# https://github.com/overleaf/internal/ - -services: - test_unit: - build: - context: ../.. - dockerfile: services/project-history/Dockerfile - target: base - volumes: - - .:/overleaf/services/project-history - - ../../node_modules:/overleaf/node_modules - - ../../libraries:/overleaf/libraries - - ../../package.json:/overleaf/package.json - - ../../.yarnrc.yml:/overleaf/.yarnrc.yml - - ../../yarn.lock:/overleaf/yarn.lock - - ../../.yarn:/overleaf/.yarn - - ../../bin/shared/wait_for_it:/overleaf/bin/shared/wait_for_it - - ../../tools/migrations:/overleaf/tools/migrations - working_dir: /overleaf/services/project-history - environment: - MOCHA_GREP: ${MOCHA_GREP} - LOG_LEVEL: ${LOG_LEVEL:-} - MONGO_CONNECTION_STRING: mongodb://mongo/test-overleaf - NODE_ENV: test - NODE_OPTIONS: "--unhandled-rejections=strict" - REDIS_HOST: redis_test - HISTORY_REDIS_HOST: redis_test - QUEUES_REDIS_HOST: redis_test - ANALYTICS_QUEUES_REDIS_HOST: redis_test - ANALYTICS_QUEUES_BQ_SINK_REDIS_HOST: redis_test - entrypoint: /overleaf/bin/shared/wait_for_it mongo:27017 --timeout=60 -- - command: yarn run --silent test:unit - user: node - depends_on: - mongo: - condition: service_started - redis_test: - condition: service_healthy - - test_acceptance: - build: - context: ../.. - dockerfile: services/project-history/Dockerfile - target: base - volumes: - - .:/overleaf/services/project-history - - ../../node_modules:/overleaf/node_modules - - ../../libraries:/overleaf/libraries - - ../../package.json:/overleaf/package.json - - ../../.yarnrc.yml:/overleaf/.yarnrc.yml - - ../../yarn.lock:/overleaf/yarn.lock - - ../../.yarn:/overleaf/.yarn - - ../../bin/shared/wait_for_it:/overleaf/bin/shared/wait_for_it - - ../../tools/migrations:/overleaf/tools/migrations - working_dir: /overleaf/services/project-history - environment: - RETRIES: - REDIS_HOST: redis_test - HISTORY_REDIS_HOST: redis_test - QUEUES_REDIS_HOST: redis_test - ANALYTICS_QUEUES_REDIS_HOST: redis_test - ANALYTICS_QUEUES_BQ_SINK_REDIS_HOST: redis_test - MONGO_CONNECTION_STRING: mongodb://mongo/test-overleaf - POSTGRES_HOST: postgres - MOCHA_GREP: ${MOCHA_GREP} - LOG_LEVEL: ${LOG_LEVEL:-} - NODE_ENV: test - NODE_OPTIONS: "--unhandled-rejections=strict" - user: node - depends_on: - mongo: - condition: service_started - redis_test: - condition: service_healthy - entrypoint: /overleaf/bin/shared/wait_for_it mongo:27017 --timeout=60 -- - command: yarn run --silent test:acceptance - - redis_test: - image: redis:7.4.8 - healthcheck: - test: ping=$$(redis-cli ping) && [ "$$ping" = 'PONG' ] - interval: 1s - retries: 20 - - mongo: - image: mongo:8.0.11 - command: --replSet overleaf --notablescan - volumes: - - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js - - ../../bin/shared/mongodb-docker-entrypoint-wait.sh:/mongodb-docker-entrypoint-wait.sh - entrypoint: /mongodb-docker-entrypoint-wait.sh - environment: - MONGO_INITDB_DATABASE: sharelatex - extra_hosts: - # Required when using the automatic database setup for initializing the - # replica set. This override is not needed when running the setup after - # starting up mongo. - - mongo:127.0.0.1 diff --git a/services/real-time/.nvmrc b/services/real-time/.nvmrc deleted file mode 100644 index 8e35034890..0000000000 --- a/services/real-time/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -24.14.1 diff --git a/services/real-time/Dockerfile b/services/real-time/Dockerfile deleted file mode 100644 index 56103a946b..0000000000 --- a/services/real-time/Dockerfile +++ /dev/null @@ -1,49 +0,0 @@ -# This file was auto-generated, do not edit it directly. -# Instead run bin/update_build_scripts from -# https://github.com/overleaf/internal/ - -FROM node:24.14.1 AS base - -# Corepack setup, shared between all the images. -ENV PATH="/overleaf/node_modules/.bin:$PATH" -ENV COREPACK_HOME=/opt/corepack -RUN corepack enable && corepack install -g yarn@4.14.1 -ENV COREPACK_ENABLE_NETWORK=0 - -WORKDIR /overleaf/services/real-time - -# Google Cloud Storage needs a writable $HOME/.config for resumable uploads -# (see https://googleapis.dev/nodejs/storage/latest/File.html#createWriteStream) -RUN mkdir /home/node/.config && chown node:node /home/node/.config - -FROM base AS app - -COPY package.json yarn.lock .yarnrc.yml /overleaf/ -COPY libraries/eslint-plugin/package.json /overleaf/libraries/eslint-plugin/package.json -COPY libraries/fetch-utils/package.json /overleaf/libraries/fetch-utils/package.json -COPY libraries/logger/package.json /overleaf/libraries/logger/package.json -COPY libraries/metrics/package.json /overleaf/libraries/metrics/package.json -COPY libraries/o-error/package.json /overleaf/libraries/o-error/package.json -COPY libraries/promise-utils/package.json /overleaf/libraries/promise-utils/package.json -COPY libraries/redis-wrapper/package.json /overleaf/libraries/redis-wrapper/package.json -COPY libraries/settings/package.json /overleaf/libraries/settings/package.json -COPY libraries/validation-tools/package.json /overleaf/libraries/validation-tools/package.json -COPY services/real-time/package.json /overleaf/services/real-time/package.json -COPY .yarn/patches/ /overleaf/.yarn/patches/ - -RUN cd /overleaf && yarn workspaces focus @overleaf/real-time overleaf -COPY libraries/eslint-plugin/ /overleaf/libraries/eslint-plugin/ -COPY libraries/fetch-utils/ /overleaf/libraries/fetch-utils/ -COPY libraries/logger/ /overleaf/libraries/logger/ -COPY libraries/metrics/ /overleaf/libraries/metrics/ -COPY libraries/o-error/ /overleaf/libraries/o-error/ -COPY libraries/promise-utils/ /overleaf/libraries/promise-utils/ -COPY libraries/redis-wrapper/ /overleaf/libraries/redis-wrapper/ -COPY libraries/settings/ /overleaf/libraries/settings/ -COPY libraries/validation-tools/ /overleaf/libraries/validation-tools/ -COPY services/real-time/ /overleaf/services/real-time/ - -FROM app -USER node - -CMD ["node", "--expose-gc", "app.js"] diff --git a/services/real-time/Makefile b/services/real-time/Makefile deleted file mode 100644 index adcbd7e801..0000000000 --- a/services/real-time/Makefile +++ /dev/null @@ -1,205 +0,0 @@ -# This file was auto-generated, do not edit it directly. -# Instead run bin/update_build_scripts from -# https://github.com/overleaf/internal/ - -export BUILD_NUMBER ?= local -export BRANCH_NAME ?= $(shell git rev-parse --abbrev-ref HEAD) -export BRANCH_NAME_TAG_SAFE = $(shell echo $(BRANCH_NAME) | sed 's/\//\-\-/g' | cut -c1-96) -export PROJECT_NAME = real-time -BUILD_DIR_NAME = $(shell pwd | xargs basename | tr -cd '[a-zA-Z0-9_.\-]') -HERE=$(shell pwd) -export MONOREPO ?= $(shell cd ../../ && pwd) -export RETRIES ?= $(if $(CI),3,0) -IMAGE_CI ?= ci/$(PROJECT_NAME):$(BRANCH_NAME_TAG_SAFE)-$(BUILD_NUMBER) -IMAGE_REPO ?= us-east1-docker.pkg.dev/overleaf-ops/ol-docker/$(PROJECT_NAME) -IMAGE_REPO_FINAL ?= $(IMAGE_REPO):$(BRANCH_NAME_TAG_SAFE)-$(BUILD_NUMBER) -IMAGE_CACHE ?= $(IMAGE_REPO):cache-$(shell cat \ - $(MONOREPO)/package.json \ - $(MONOREPO)/yarn.lock \ - $(MONOREPO)/libraries/eslint-plugin/package.json \ - $(MONOREPO)/libraries/fetch-utils/package.json \ - $(MONOREPO)/libraries/logger/package.json \ - $(MONOREPO)/libraries/metrics/package.json \ - $(MONOREPO)/libraries/o-error/package.json \ - $(MONOREPO)/libraries/promise-utils/package.json \ - $(MONOREPO)/libraries/redis-wrapper/package.json \ - $(MONOREPO)/libraries/settings/package.json \ - $(MONOREPO)/libraries/validation-tools/package.json \ - $(MONOREPO)/services/real-time/package.json \ - $(MONOREPO)/.yarn/patches/* \ -| sha256sum | cut -d '-' -f1) -export MOCHA_GREP ?= - -DOCKER_COMPOSE_FLAGS ?= -f docker-compose.yml -DOCKER_COMPOSE := docker compose ${DOCKER_COMPOSE_FLAGS} - -# Common flags for docker compose run. -# --rm Tear down test runner container -# --build Make sure that we use the latest base image -# --quiet-build Hide build output -ifeq ($(CI),true) - DC_RUN_FLAGS := --rm -else - DC_RUN_FLAGS := --rm --build --quiet-build -endif - -COMPOSE_PROJECT_NAME_TEST_ACCEPTANCE ?= test_acceptance_$(BUILD_DIR_NAME) -DOCKER_COMPOSE_TEST_ACCEPTANCE = \ - COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME_TEST_ACCEPTANCE) $(DOCKER_COMPOSE) - -COMPOSE_PROJECT_NAME_TEST_UNIT ?= test_unit_$(BUILD_DIR_NAME) -DOCKER_COMPOSE_TEST_UNIT = \ - COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME_TEST_UNIT) $(DOCKER_COMPOSE) - -.PHONY: print-branch-tag-safe -print-branch-tag-safe: - @echo $(BRANCH_NAME_TAG_SAFE) - -clean: - -docker rmi $(IMAGE_CI) - -docker rmi $(IMAGE_REPO_FINAL) - -$(DOCKER_COMPOSE_TEST_UNIT) down --remove-orphans --rmi local --timeout 0 --volumes - -$(DOCKER_COMPOSE_TEST_ACCEPTANCE) down --remove-orphans --rmi local --timeout 0 --volumes - -rm -rf reports/ - -# Run the linting commands in the scope of the monorepo. -# Eslint and prettier (plus some configs) are on the root. -RUN_LINTING = ../../bin/run -w /overleaf/services/$(PROJECT_NAME) monorepo yarn run --silent -RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent - -RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/real-time/reports:/overleaf/services/real-time/reports --volume $(MONOREPO)/.cache:/overleaf/.cache $(IMAGE_CI) yarn run --silent -RUN_LINTING_CI_MONOREPO = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/real-time/reports:/overleaf/services/real-time/reports --volume $(MONOREPO)/.cache:/overleaf/.cache -w /overleaf $(IMAGE_CI) yarn run --silent - -SHELLCHECK_OPTS = \ - --shell=bash \ - --external-sources -SHELLCHECK_COLOR := $(if $(CI),--color=never,--color) -SHELLCHECK_FILES := { git ls-files "*.sh" -z; git grep -Plz "\A\#\!.*bash"; } | sort -zu - -shellcheck: - @$(SHELLCHECK_FILES) | xargs -0 -r docker run --rm -v $(HERE):/mnt -w /mnt \ - koalaman/shellcheck:stable $(SHELLCHECK_OPTS) $(SHELLCHECK_COLOR) - -shellcheck_fix: - @$(SHELLCHECK_FILES) | while IFS= read -r -d '' file; do \ - diff=$$(docker run --rm -v $(HERE):/mnt -w /mnt koalaman/shellcheck:stable $(SHELLCHECK_OPTS) --format=diff "$$file" 2>/dev/null); \ - if [ -n "$$diff" ] && ! echo "$$diff" | patch -p1 >/dev/null 2>&1; then echo "\033[31m$$file\033[0m"; \ - elif [ -n "$$diff" ]; then echo "$$file"; \ - else echo "\033[2m$$file\033[0m"; fi \ - done - -WITH_CACHE_FOLDER = \ - lint \ - lint_ci \ - lint_fix \ - format \ - format_ci \ - format_fix \ - typecheck \ - typecheck_ci \ - -$(WITH_CACHE_FOLDER): ../../.cache/prettier/ -../../.cache/prettier/: - mkdir -p $@ - -$(WITH_CACHE_FOLDER): ../../.cache/eslint/ -../../.cache/eslint/: - mkdir -p $@ - -format: - $(RUN_LINTING_MONOREPO) format services/real-time - -format_ci: - $(RUN_LINTING_CI_MONOREPO) format services/real-time - -format_fix: - $(RUN_LINTING_MONOREPO) format:fix services/real-time - -lint: - $(RUN_LINTING) lint - -lint_ci: - -$(RUN_LINTING_CI) lint --format json --output-file reports/eslint.json - sed -i 's_"filePath":"/overleaf_"filePath":"$(MONOREPO)_g' reports/eslint.json - -lint_fix: - $(RUN_LINTING) lint:fix - -typecheck: - $(RUN_LINTING) types:check - -typecheck_ci: - $(RUN_LINTING_CI) types:check - -test: format lint typecheck shellcheck test_unit test_acceptance - -test_unit: -ifneq (,$(wildcard test/unit)) - $(DOCKER_COMPOSE_TEST_UNIT) run $(DC_RUN_FLAGS) test_unit -endif - -test_clean: test_unit_clean -test_unit_clean: -ifneq (,$(wildcard test/unit)) - $(DOCKER_COMPOSE_TEST_UNIT) down -v -t 0 -endif - -test_acceptance: test_acceptance_clean test_acceptance_pre_run test_acceptance_run - $(MAKE) test_acceptance_clean - -test_acceptance_debug: test_acceptance_clean test_acceptance_pre_run test_acceptance_run_debug - $(MAKE) test_acceptance_clean - -test_acceptance_run: -ifneq (,$(wildcard test/acceptance)) - $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run $(DC_RUN_FLAGS) test_acceptance -endif - -test_acceptance_run_debug: -ifneq (,$(wildcard test/acceptance)) - $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run -p 127.0.0.9:19999:19999 $(DC_RUN_FLAGS) test_acceptance yarn run --inspect=0.0.0.0:19999 --inspect-brk test:acceptance -endif - -test_clean: test_acceptance_clean -test_acceptance_clean: - $(DOCKER_COMPOSE_TEST_ACCEPTANCE) down -v -t 0 - -test_acceptance_pre_run: -ifneq (,$(wildcard test/acceptance/js/scripts/pre-run)) - $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run $(DC_RUN_FLAGS) test_acceptance test/acceptance/js/scripts/pre-run -endif - -benchmarks: - $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run $(DC_RUN_FLAGS) test_acceptance yarn run benchmarks - -build: - docker build \ - --pull \ - --build-arg BUILDKIT_INLINE_CACHE=1 \ - --tag $(IMAGE_CI) \ - --tag $(IMAGE_CACHE) \ - --tag $(IMAGE_REPO_FINAL) \ - --cache-from $(IMAGE_CACHE) \ - --file Dockerfile \ - ../.. - -tar: - $(DOCKER_COMPOSE) up tar - -push: - docker push $(IMAGE_REPO_FINAL) - -push_branch: - docker push $(IMAGE_CACHE) - -.PHONY: clean \ - format format_fix \ - lint lint_fix \ - build_types typecheck \ - lint_ci format_ci typecheck_ci \ - shellcheck shellcheck_fix \ - test test_clean test_unit test_unit_clean \ - test_acceptance test_acceptance_debug test_acceptance_pre_run \ - test_acceptance_run test_acceptance_run_debug test_acceptance_clean \ - benchmarks \ - build tar publish \ diff --git a/services/real-time/buildscript.txt b/services/real-time/buildscript.txt deleted file mode 100644 index f82389c997..0000000000 --- a/services/real-time/buildscript.txt +++ /dev/null @@ -1,10 +0,0 @@ -real-time ---dependencies=redis ---env-add= ---env-pass-through= ---esmock-loader=False ---node-version=24.14.1 ---pipeline-owner=32 ---public-repo=False ---test-unit-vitest=True ---tsconfig-extra-includes=vitest.config.unit.cjs diff --git a/services/real-time/docker-compose.ci.yml b/services/real-time/docker-compose.ci.yml deleted file mode 100644 index f96e3cb87f..0000000000 --- a/services/real-time/docker-compose.ci.yml +++ /dev/null @@ -1,64 +0,0 @@ -# This file was auto-generated, do not edit it directly. -# Instead run bin/update_build_scripts from -# https://github.com/overleaf/internal/ - -services: - test_unit: - image: ci/$PROJECT_NAME:$BRANCH_NAME_TAG_SAFE-$BUILD_NUMBER - user: node - volumes: - - ./reports:/overleaf/services/real-time/reports - - ../../tsconfig.backend.json:/overleaf/tsconfig.backend.json - command: yarn run test:unit:_run - environment: - CI: - MONGO_CONNECTION_STRING: mongodb://mongo/test-overleaf - NODE_ENV: test - NODE_OPTIONS: "--unhandled-rejections=strict" - VITEST_NO_CACHE: true - REDIS_HOST: redis_test - QUEUES_REDIS_HOST: redis_test - HISTORY_REDIS_HOST: redis_test - ANALYTICS_QUEUES_REDIS_HOST: redis_test - ANALYTICS_QUEUES_BQ_SINK_REDIS_HOST: redis_test - depends_on: - redis_test: - condition: service_healthy - - test_acceptance: - image: ci/$PROJECT_NAME:$BRANCH_NAME_TAG_SAFE-$BUILD_NUMBER - environment: - CI: - RETRIES: - REDIS_HOST: redis_test - QUEUES_REDIS_HOST: redis_test - HISTORY_REDIS_HOST: redis_test - ANALYTICS_QUEUES_REDIS_HOST: redis_test - ANALYTICS_QUEUES_BQ_SINK_REDIS_HOST: redis_test - MONGO_HOST: mongo - POSTGRES_HOST: postgres - MOCHA_GREP: ${MOCHA_GREP} - NODE_ENV: test - NODE_OPTIONS: "--unhandled-rejections=strict" - volumes: - - ./reports:/overleaf/services/real-time/reports - depends_on: - redis_test: - condition: service_healthy - user: node - command: yarn run test:acceptance - - tar: - build: . - image: ci/$PROJECT_NAME:$BRANCH_NAME_TAG_SAFE-$BUILD_NUMBER - volumes: - - ./:/tmp/build/ - command: tar -czf /tmp/build/build.tar.gz --exclude=build.tar.gz --exclude-vcs . - user: root - - redis_test: - image: redis:7.4.8 - healthcheck: - test: ping="$$(redis-cli ping)" && [ "$$ping" = 'PONG' ] - interval: 1s - retries: 20 diff --git a/services/real-time/docker-compose.yml b/services/real-time/docker-compose.yml deleted file mode 100644 index 678d96e22d..0000000000 --- a/services/real-time/docker-compose.yml +++ /dev/null @@ -1,76 +0,0 @@ -# This file was auto-generated, do not edit it directly. -# Instead run bin/update_build_scripts from -# https://github.com/overleaf/internal/ - -services: - test_unit: - build: - context: ../.. - dockerfile: services/real-time/Dockerfile - target: base - volumes: - - .:/overleaf/services/real-time - - ../../node_modules:/overleaf/node_modules - - ../../libraries:/overleaf/libraries - - ../../package.json:/overleaf/package.json - - ../../.yarnrc.yml:/overleaf/.yarnrc.yml - - ../../yarn.lock:/overleaf/yarn.lock - - ../../.yarn:/overleaf/.yarn - - ../../tsconfig.backend.json:/overleaf/tsconfig.backend.json - working_dir: /overleaf/services/real-time - environment: - MOCHA_GREP: ${MOCHA_GREP} - LOG_LEVEL: ${LOG_LEVEL:-} - MONGO_CONNECTION_STRING: mongodb://mongo/test-overleaf - NODE_ENV: test - NODE_OPTIONS: "--unhandled-rejections=strict" - REDIS_HOST: redis_test - HISTORY_REDIS_HOST: redis_test - QUEUES_REDIS_HOST: redis_test - ANALYTICS_QUEUES_REDIS_HOST: redis_test - ANALYTICS_QUEUES_BQ_SINK_REDIS_HOST: redis_test - command: yarn run --silent test:unit - user: node - depends_on: - redis_test: - condition: service_healthy - - test_acceptance: - build: - context: ../.. - dockerfile: services/real-time/Dockerfile - target: base - volumes: - - .:/overleaf/services/real-time - - ../../node_modules:/overleaf/node_modules - - ../../libraries:/overleaf/libraries - - ../../package.json:/overleaf/package.json - - ../../.yarnrc.yml:/overleaf/.yarnrc.yml - - ../../yarn.lock:/overleaf/yarn.lock - - ../../.yarn:/overleaf/.yarn - working_dir: /overleaf/services/real-time - environment: - RETRIES: - REDIS_HOST: redis_test - HISTORY_REDIS_HOST: redis_test - QUEUES_REDIS_HOST: redis_test - ANALYTICS_QUEUES_REDIS_HOST: redis_test - ANALYTICS_QUEUES_BQ_SINK_REDIS_HOST: redis_test - MONGO_CONNECTION_STRING: mongodb://mongo/test-overleaf - POSTGRES_HOST: postgres - MOCHA_GREP: ${MOCHA_GREP} - LOG_LEVEL: ${LOG_LEVEL:-} - NODE_ENV: test - NODE_OPTIONS: "--unhandled-rejections=strict" - user: node - depends_on: - redis_test: - condition: service_healthy - command: yarn run --silent test:acceptance - - redis_test: - image: redis:7.4.8 - healthcheck: - test: ping=$$(redis-cli ping) && [ "$$ping" = 'PONG' ] - interval: 1s - retries: 20 diff --git a/services/web/.github/prompts/await-migration.prompt.md b/services/web/.github/prompts/await-migration.prompt.md deleted file mode 100644 index 26ee1ee114..0000000000 --- a/services/web/.github/prompts/await-migration.prompt.md +++ /dev/null @@ -1,389 +0,0 @@ ---- -mode: 'agent' -description: 'Generate a clear code explanation with examples' ---- - -# Improved Async/Await Migration Instructions - -Based on lessons learned from PR #28840 and reviewer feedback, these comprehensive instructions address the original migration requirements while preventing common issues. - -## Core Migration Principles - -### 1. Function Signature Transformation - -- Convert callback-style functions to async/await -- Remove callback parameters from function signatures -- Add `async` keyword to function declarations -- Replace `return callback(err, result)` with `throw err` or `return result` -- Replace `return callback()` with `return` (or `return undefined`) - -### 2. Error Handling Patterns - -#### OError.tag Usage - CRITICAL UPDATE - -**DO NOT** wrap simple operations in try/catch just to tag errors with OError. With async/await, the stack trace is preserved automatically, making OError.tag less necessary for basic error propagation. - -```javascript -// OLD (callback style) - OError.tag was needed -callback(err) => { - OError.tag(err, 'description', { context }) - return callback(err) -} - -// BAD (unnecessary with async/await) -try { - await operation() -} catch (err) { - throw OError.tag(err, 'description', { context }) -} - -// GOOD (let errors propagate naturally) -await operation() - -// ONLY use OError.tag when adding meaningful context or transforming errors -try { - await complexOperation() -} catch (err) { - if (err.code === 'SPECIFIC_ERROR') { - throw OError.tag(err, 'meaningful context about why this failed', { - important_context: value - }) - } - throw err // let other errors propagate unchanged -} -``` - -### 3. Concurrency Considerations - CRITICAL - -#### Sequential vs Parallel Operations - -**Be extremely cautious when converting from serial to parallel operations.** The original code's choice of sequential processing is often intentional. - -```javascript -// OLD - Sequential processing (often intentional) -Async.mapSeries(items, processItem, callback) - -// BAD - Unbounded parallel processing -await Promise.all(items.map(processItem)) - -// BETTER - Keep sequential if unsure about resource limits -for (const item of items) { - await processItem(item) -} - -// GOOD - Controlled batch processing for performance -const BATCH_SIZE = 10 -for (let i = 0; i < items.length; i += BATCH_SIZE) { - const batch = items.slice(i, i + BATCH_SIZE) - await Promise.all(batch.map(processItem)) -} - -// IDEAL - Use Redis MGET for multiple key retrieval -// Instead of: Promise.all(keys.map(k => redis.get(k))) -const values = await redis.mget(keys) -``` - -#### Database/Redis Operation Guidelines - -- **Never** send unbounded parallel requests to databases -- **Prefer** sequential processing for database operations unless there's a specific performance need -- **Consider** batch operations (like Redis MGET/MSET) for multiple operations -- **Implement** maximum concurrency limits when parallel processing is necessary - -### 4. Background Operations - -#### Fire-and-Forget Pattern - -When operations were called in the background (with empty callbacks), preserve this behavior: - -```javascript -// OLD - Background operation with ignored callback -someOperation(user, function () {}) // errors swallowed - -// GOOD - Preserve background behavior -someOperation(user).catch(err => { - logger.error({ err }, 'Failed to run background operation') -}) - -// Or if truly fire-and-forget: -someOperation(user).catch(() => {}) // explicitly ignore errors -``` - -### 5. Module Export Patterns - -#### Using callbackifyAll for Dual API - -```javascript -const { callbackifyAll } = require('@overleaf/promise-utils') - -const MyModule = { - async myMethod(param) { - // async implementation - }, -} - -const moduleExports = { - ...callbackifyAll(MyModule), // callback API - promises: MyModule, // promise API -} - -module.exports = moduleExports -``` - -#### Internal Method Stubbing (for testing) - -**Only** add method binding patterns when tests need to stub internal method calls: - -```javascript -// ONLY if tests need to stub internal calls to _internalMethod -MyModule._internalMethod = (...args) => moduleExports._internalMethod(...args) -``` - -**Do NOT** expose internal methods at the top level - they should be accessible via `moduleExports.promises._internalMethod`. - -### 6. Test Migration Patterns - -#### Async Test Conversion - -```javascript -// OLD -it('should do something', function (done) { - MyModule.method(param, function (err, result) { - expect(err).to.not.exist - expect(result).to.equal(expected) - done() - }) -}) - -// NEW -it('should do something', async function () { - const result = await MyModule.promises.method(param) - expect(result).to.equal(expected) -}) -``` - -#### Mock/Stub Patterns - -```javascript -// For Redis or database mocks, ensure method chaining works -beforeEach(function () { - redis.multi = sinon.stub().returns({ - sadd: sinon.stub().returnsThis(), - pexpire: sinon.stub().returnsThis(), - exec: sinon.stub().resolves(), - }) -}) -``` - -### 7. Specific Redis Patterns - -#### Multi-Transaction Operations - -```javascript -// Correct pattern for Redis multi operations -const multi = redis.multi() -multi.sadd(key, value) -multi.pexpire(key, ttl) -await multi.exec() -``` - -#### Single vs Multiple Key Operations - -```javascript -// BAD - Multiple individual operations -const values = await Promise.all(keys.map(k => redis.get(k))) - -// GOOD - Use batch operations when available -const values = await redis.mget(keys) -``` - -## Migration Checklist - -### Before Starting - -- [ ] Understand the original code's concurrency patterns -- [ ] Identify any background operations that should remain non-blocking -- [ ] Check if Redis batch operations can replace individual operations -- [ ] Look for internal method calls that might need test stubbing - -### During Migration - -- [ ] Convert function signatures (remove callbacks, add async) -- [ ] Replace callback patterns with await -- [ ] Handle early returns properly -- [ ] Preserve sequential processing unless there's a clear performance benefit -- [ ] Keep background operations non-blocking -- [ ] Avoid unnecessary OError.tag wrapping -- [ ] Update JSDoc comments to remove callback parameters - -### After Migration - -- [ ] Run comprehensive tests (fix Docker/environment issues if needed) -- [ ] Verify all background operations still work correctly -- [ ] Check that internal method calls can be stubbed if needed -- [ ] Ensure database operations don't overwhelm resources -- [ ] Validate error handling preserves meaningful context -- [ ] **Remove all decaffeinate artifacts from both implementation AND test files** -- [ ] Add explanatory comments for any non-obvious technical patterns -- [ ] Avoid selfRef patterns - use module exports routing instead - -### Test Migration - -- [ ] **Run tests EARLY and OFTEN during migration process** -- [ ] Convert test functions to async -- [ ] Update assertion patterns -- [ ] Fix mock/stub configurations for chained operations (Redis multi, etc.) -- [ ] Verify all test scenarios still pass -- [ ] Remove duplicate or unnecessary mock setups -- [ ] Clean up decaffeinate comments from test files -- [ ] Ensure internal method stubs work through promises interface - -## Critical Lessons from Real Migration Experience - -### 1. Testing Environment Issues - -**ALWAYS run tests early and often during migration.** Don't wait until the end. - -Common test running problems: - -- Docker containers may need cleanup: `docker system prune -f` -- Use specific test grep patterns: `MOCHA_GREP="ModuleName" make test_unit_app` -- Mock objects must return proper objects for chaining (e.g., `multi()` must return `{method: stub().returnsThis(), ...}`) - -### 2. Method Stubbing for Internal Calls - -When methods call other methods internally, tests may need to stub those calls: - -```javascript -// If methodA() calls methodB() internally and tests need to verify this: -// DON'T do this - creates unnecessary complexity: -const selfRef = { ... }; // BAD pattern - -// DO this - route through the module exports interface: -moduleExports.promises.methodB(params).catch(err => { - logger.error({ err }, 'Failed to run background operation') -}) - -// Add a brief comment explaining the routing pattern: -// Route through moduleExports so tests can stub this call -``` - -### 3. Avoid the selfRef Pattern - -The `selfRef` pattern should be avoided - it's a code smell that indicates better module structure is needed: - -```javascript -// BAD - selfRef pattern -const selfRef = {} -selfRef.methodA = async function() { - await selfRef.methodB() // circular reference -} - -// GOOD - route through module exports when stubbing is needed -async methodA() { - await moduleExports.promises.methodB() // testable -} -``` - -### 4. Complete Decaffeinate Cleanup - -**Remove ALL legacy CoffeeScript artifacts** - this is a required part of the migration. - -Look for and remove patterns like these (exact format may vary): - -```javascript -/* eslint-disable */ -// TODO: This file was created by bulk-decaffeinate. - -/* eslint-disable - camelcase, - n/handle-callback-err, - max-len, - no-return-assign, - no-unused-vars, -*/ - -// Fix any style issues and re-enable lint. - -/* - * decaffeinate suggestions: - * DS102: Remove unnecessary code created because of implicit returns - * DS207: Consider shorter variations of null checks - * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md - */ -``` - -**Search patterns to look for:** - -- Comments containing "bulk-decaffeinate" or "decaffeinate" -- Large `/* eslint-disable */` blocks at the top of files -- Comments about "Fix any style issues and re-enable lint" -- "decaffeinate suggestions" comment blocks - -Check **both implementation AND test files** for these artifacts. - -### 5. Add Explanatory Comments for Non-Obvious Code - -When you need to write "ugly" code for unavoidable technical reasons, add a brief comment explaining why: - -```javascript -// Background operation - preserve fire-and-forget behavior -// Route through moduleExports so tests can stub this call -moduleExports.promises._checkSessions(user).catch(err => { - logger.error({ err }, 'Failed to check sessions in background') -}) -``` - -This prevents future developers from "refactoring" the code and breaking functionality. - -### 6. Simplify OError.tag() Usage - -**With async/await, many OError.tag() wrappers can be removed.** OError.tag() was primarily used to preserve stack traces across callback boundaries, but async/await handles this automatically. - -```javascript -// BEFORE - callback era (needed OError.tag for stack traces) -try { - await redis.multi().sadd(key, value).exec() -} catch (err) { - throw OError.tag(err, 'error adding to redis set', { key }) -} - -// AFTER - async/await preserves stack traces naturally -await redis.multi().sadd(key, value).exec() -``` - -**Keep OError.tag() only when:** - -- Adding meaningful context that aids debugging -- Transforming low-level errors into domain-specific errors -- The wrapper adds significant value beyond just a descriptive message - -**Remove OError.tag() when:** - -- The error message doesn't add meaningful context -- The tag message just restates what the code obviously does -- Stack trace preservation was the only benefit - -## Common Pitfalls to Avoid - -1. **Over-parallelization**: Don't convert all sequential operations to parallel -2. **Unnecessary error wrapping**: Don't wrap every operation in try/catch just for OError.tag -3. **Breaking background operations**: Maintain fire-and-forget behavior where intended -4. **Exposing internal methods incorrectly**: Use the promises interface, not top-level exports -5. **Resource exhaustion**: Be mindful of database connection limits and Redis performance -6. **Test mock complexity**: Keep mocks simple and targeted to what's actually needed -7. **Using selfRef patterns**: Always route through module exports instead -8. **Forgetting decaffeinate cleanup**: Remove all legacy comments and eslint disables -9. **Not running tests early**: Run tests frequently during migration, not just at the end -10. **Missing explanatory comments**: Add brief comments for non-obvious technical patterns - -## Success Metrics - -- All existing tests pass without modification (except for async conversion) -- No new resource exhaustion issues under load -- Background operations continue to work as intended -- Error messages and logging remain informative -- Internal method stubbing works correctly for testing -- Code is cleaner and more maintainable than before - -These instructions should be applied systematically, with careful consideration of the specific context and requirements of each module being migrated. diff --git a/services/web/.nvmrc b/services/web/.nvmrc deleted file mode 100644 index 8e35034890..0000000000 --- a/services/web/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -24.14.1 diff --git a/services/web/.nycrc b/services/web/.nycrc deleted file mode 100644 index 9efcbec3b0..0000000000 --- a/services/web/.nycrc +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extension": [".js", ".cjs", ".mjs", ".ts"], - "cwd": "../../", - "produce-source-map": true, - "source-map": true, - "temp-dir": "/tmp/.nyc_output" -} diff --git a/services/web/.storybook/index.html.m4 b/services/web/.storybook/index.html.m4 deleted file mode 100644 index 1908abebeb..0000000000 --- a/services/web/.storybook/index.html.m4 +++ /dev/null @@ -1,78 +0,0 @@ -divert(`-1') -define(`foreach', `pushdef(`$1')_foreach($@)popdef(`$1')') -define(`_arg1', `$1') -define(`_foreach', `ifelse(`$2', `()', `', - `define(`$1', _arg1$2)$3`'$0(`$1', (shift$2), `$3')')') -divert`'dnl - - - - Overleaf Storybook builds - - - - -

Overleaf Storybook builds

-

Branches:

-
    - foreach(DIR, (LIST), -
  • - DIR/ - - GitHub - -
  • - ) -
- - Last updated on syscmd(date)dnl - for ifdef(`BRANCH_NAME',BRANCH_NAME,unknown branch) - (build history). - - - diff --git a/services/web/.storybook/main.ts b/services/web/.storybook/main.ts deleted file mode 100644 index 462e56cfb8..0000000000 --- a/services/web/.storybook/main.ts +++ /dev/null @@ -1,159 +0,0 @@ -// This file has been automatically migrated to valid ESM format by Storybook. -import { fileURLToPath } from 'node:url' -import { createRequire } from 'node:module' -import { defineMain } from '@storybook/react-webpack5/node' -import path, { dirname } from 'node:path' -import MiniCssExtractPlugin from 'mini-css-extract-plugin' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = dirname(__filename) -const require = createRequire(import.meta.url) - -const rootDir = path.resolve(__dirname, '..') - -// NOTE: must be set before webpack config is imported -process.env.OVERLEAF_CONFIG = path.join(rootDir, 'config/settings.webpack.js') - -function getAbsolutePath(value: string): any { - return path.dirname(require.resolve(path.join(value, 'package.json'))) -} - -// Make sure that babel-macros are re-evaluated after changing the modules config -// Import this after setting process.env.OVERLEAF_CONFIG -const invalidateBabelCacheIfNeeded = require('../frontend/macros/invalidate-babel-cache-if-needed') -invalidateBabelCacheIfNeeded() - -export default defineMain({ - core: { - disableTelemetry: true, - }, - - staticDirs: [path.join(rootDir, 'public')], - - stories: [ - path.join(rootDir, 'frontend/stories/**/*.stories.{js,jsx,ts,tsx}'), - path.join(rootDir, 'modules/**/stories/**/*.stories.{js,jsx,ts,tsx}'), - path.join(rootDir, 'frontend/stories/**/*.mdx'), - path.join(rootDir, 'modules/**/stories/**/*.mdx'), - ], - - addons: [ - getAbsolutePath('@storybook/addon-links'), - getAbsolutePath('@storybook/addon-a11y'), - getAbsolutePath('@storybook/addon-designs'), - getAbsolutePath('@storybook/addon-webpack5-compiler-babel'), - { - name: getAbsolutePath('@storybook/addon-styling-webpack'), - options: { - rules: [ - { - test: /\.css$/, - use: [ - { loader: MiniCssExtractPlugin.loader }, - { loader: 'css-loader' }, - ], - }, - { - // Pass Sass files through sass-loader/css-loader/mini-css-extract- - // plugin (note: run in reverse order) - test: /\.s[ac]ss$/, - use: [ - // Allows the CSS to be extracted to a separate .css file - { loader: MiniCssExtractPlugin.loader }, - // Resolves any CSS dependencies (e.g. url()) - { loader: 'css-loader' }, - // Resolve relative paths sensibly in SASS - { loader: 'resolve-url-loader' }, - { - // Runs autoprefixer on CSS via postcss - loader: 'postcss-loader', - options: { - postcssOptions: { - plugins: ['autoprefixer'], - }, - }, - }, - // Compiles Sass to CSS - { - loader: 'sass-loader', - options: { sourceMap: true }, // sourceMap: true is required for resolve-url-loader - }, - ], - }, - ], - plugins: [new MiniCssExtractPlugin()], - }, - }, - getAbsolutePath('@storybook/addon-docs'), - ], - - framework: { - name: getAbsolutePath('@storybook/react-webpack5'), - options: {}, - }, - - babel: (options: Record) => { - return { - ...options, - plugins: [ - ['@babel/plugin-proposal-decorators', { legacy: true }], - // ensure that TSX files are transformed before other plugins run - ['@babel/plugin-transform-typescript', { isTSX: true }], - ...(options.plugins ?? []), - ], - } - }, - - webpackFinal: storybookConfig => { - return { - ...storybookConfig, - resolve: { - ...storybookConfig.resolve, - fallback: { - ...storybookConfig.resolve?.fallback, - fs: false, - os: false, - module: false, - tty: require.resolve('tty-browserify'), - }, - extensions: ['.js', '.jsx', '.mjs', '.ts', '.tsx', '.json'], - alias: { - ...storybookConfig.resolve?.alias, - // custom prefixes for import paths - '@': path.join(rootDir, 'frontend/js/'), - '@modules': path.join(rootDir, 'modules/'), - '@ol-types': path.join(rootDir, 'types/'), - '@ol-storybook': path.join(rootDir, '.storybook/'), - '@wf': path.join( - rootDir, - 'modules/writefull/frontend/js/integration/src/' - ), - }, - }, - module: { - ...storybookConfig.module, - rules: (storybookConfig.module?.rules ?? []).concat( - { - test: /\.wasm$/, - type: 'asset/resource', - generator: { - filename: 'js/[name]-[contenthash][ext]', - }, - }, - { - // Disable webpack's `new URL()` processing for pdfjs-dist worker - // files, so that webpack does not try to resolve qcms_bg.wasm and - // openjpeg.wasm (which live in pdfjs-dist/wasm/, not build/). - // The main webpack build relies on a `/* webpackIgnore: true */` - // comment (via yarn patch) to achieve the same effect, but - // storybook's babel pipeline may strip comments from the 1.9 MB - // worker file (babel `compact: "auto"` removes comments for files - // > 500 KB), so we disable URL parsing for these files instead. - test: /pdfjs-dist.*pdf\.worker.*\.m?js$/, - parser: { javascript: { url: false } }, - } - ), - }, - } - }, -}) diff --git a/services/web/.storybook/manager.css b/services/web/.storybook/manager.css deleted file mode 100644 index 7a99a82e64..0000000000 --- a/services/web/.storybook/manager.css +++ /dev/null @@ -1,3 +0,0 @@ -.sidebar-container a[title='Overleaf'] { - max-width: 100px; -} diff --git a/services/web/.storybook/manager.ts b/services/web/.storybook/manager.ts deleted file mode 100644 index 63601e9c22..0000000000 --- a/services/web/.storybook/manager.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { addons } from 'storybook/manager-api' -import { create } from 'storybook/theming/create' - -import './manager.css' - -import brandImage from '../public/img/ol-brand/overleaf.svg' - -const theme = create({ - base: 'light', - brandTitle: 'Overleaf', - brandUrl: 'https://www.overleaf.com', - brandImage, -}) - -addons.setConfig({ theme }) diff --git a/services/web/.storybook/preview.tsx b/services/web/.storybook/preview.tsx deleted file mode 100644 index 414b70ac89..0000000000 --- a/services/web/.storybook/preview.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import { definePreview } from '@storybook/react-webpack5' -import addonA11y from '@storybook/addon-a11y' -import addonDesigns from '@storybook/addon-designs' -import addonDocs from '@storybook/addon-docs' -import addonLinks from '@storybook/addon-links' - -// Storybook does not (currently) support async loading of "stories". Therefore -// the strategy in frontend/js/i18n.ts does not work (because we cannot wait on -// the promise to resolve). -// Therefore we have to use the synchronous method for configuring -// react-i18next. Because this, we can only hard-code a single language. -import i18n from 'i18next' -import { initReactI18next } from 'react-i18next' -// @ts-ignore -import en from '../../../services/web/locales/en.json' - -function resetMeta() { - window.metaAttributesCache = new Map() - window.metaAttributesCache.set('ol-i18n', { currentLangCode: 'en' }) - window.metaAttributesCache.set('ol-capabilities', ['chat']) - window.metaAttributesCache.set('ol-compileSettings', { - compileTimeout: 20, - }) - window.metaAttributesCache.set('ol-ExposedSettings', { - adminEmail: 'placeholder@example.com', - appName: 'Overleaf', - cookieDomain: '.overleaf.stories', - dropboxAppName: 'Overleaf-Stories', - emailConfirmationDisabled: false, - enableSubscriptions: true, - hasAffiliationsFeature: false, - hasLinkUrlFeature: true, - hasLinkedProjectFileFeature: true, - hasLinkedProjectOutputFileFeature: true, - hasSamlFeature: true, - ieeeBrandId: 15, - isOverleaf: true, - labsEnabled: true, - maxEntitiesPerProject: 10, - maxUploadSize: 5 * 1024 * 1024, - recaptchaDisabled: { - invite: true, - login: true, - passwordReset: true, - register: true, - addEmail: true, - }, - sentryAllowedOriginRegex: '', - siteUrl: 'http://localhost', - templateLinks: [], - textExtensions: [ - 'tex', - 'latex', - 'sty', - 'cls', - 'bst', - 'bib', - 'bibtex', - 'txt', - 'tikz', - 'mtx', - 'rtex', - 'md', - 'asy', - 'lbx', - 'bbx', - 'cbx', - 'm', - 'lco', - 'dtx', - 'ins', - 'ist', - 'def', - 'clo', - 'ldf', - 'rmd', - 'lua', - 'gv', - 'mf', - 'lhs', - 'lean', - 'lean4', - 'hs', - 'mk', - 'xmpdata', - 'cfg', - 'rnw', - 'ltx', - 'inc', - ], - editableFilenames: ['latexmkrc', '.latexmkrc', 'makefile', 'gnumakefile'], - validRootDocExtensions: ['tex', 'Rtex', 'ltx', 'Rnw'], - fileIgnorePattern: - '**/{{__MACOSX,.git,.texpadtmp,.R}{,/**},.!(latexmkrc),*.{dvi,aux,log,toc,out,pdfsync,synctex,synctex(busy),fdb_latexmk,fls,nlo,ind,glo,gls,glg,bbl,blg,doc,docx,gz,swp}}', - projectUploadTimeout: 12000, - }) -} - -i18n.use(initReactI18next).init({ - lng: 'en', - - // still using the v3 plural suffixes - compatibilityJSON: 'v3', - - resources: { - en: { translation: en }, - }, - - react: { - useSuspense: false, - transSupportBasicHtmlNodes: false, - }, - - interpolation: { - prefix: '__', - suffix: '__', - unescapeSuffix: 'HTML', - skipOnVariables: true, - escapeValue: false, - defaultVariables: { - appName: 'Overleaf', - }, - }, -}) - -export default definePreview({ - addons: [addonA11y(), addonDesigns(), addonDocs(), addonLinks()], - parameters: { - // Automatically mark prop-types like onClick, onToggle, etc as Storybook - // "actions", so that they are logged in the Actions pane at the bottom of the - // viewer - actions: { argTypesRegex: '^on.*' }, - docs: { - story: { - // render stories in iframes, to isolate modals - inline: false, - }, - }, - options: { - storySort: { - method: 'alphabetical', - order: [ - 'Storybook Guideline', - ['Foundations', 'Storybook builds', 'Feature Flags'], - 'Shared', - ], - }, - }, - }, - globalTypes: { - theme: { - name: 'Theme', - description: 'Editor theme', - defaultValue: 'main-', - toolbar: { - icon: 'circlehollow', - items: [ - { value: 'main-', title: 'Default' }, - { value: 'main-light-', title: 'Light' }, - ], - }, - }, - }, - loaders: [ - async () => { - return { - mainStyle: await import( - // @ts-ignore - `!!to-string-loader!css-loader!resolve-url-loader!sass-loader!../../../services/web/frontend/stylesheets/main-style.scss` - ), - } - }, - ], - decorators: [ - (Story, context) => { - const { mainStyle } = context.loaded - - resetMeta() - - return ( -
- {mainStyle && } - -
- ) - }, - ], -}) - -// Populate meta for top-level access in modules on import -resetMeta() diff --git a/services/web/.storybook/utils/figma-design-url.ts b/services/web/.storybook/utils/figma-design-url.ts deleted file mode 100644 index 570b7cd88e..0000000000 --- a/services/web/.storybook/utils/figma-design-url.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** Creates design parameters for a Storybook story conditionally. - * Helper function to generate design parameters based on the presence of a Figma access token. - * The benefit of the token is that it allows component inspection directly in Storybook. - * If token is not available, it defaults to a basic Figma URL. - * Token can be generated in your Figma account settings. - * To copy URL: In your Figma file, click to select the specific component frame you want to display in Storybook. - * It's important to select the outer frame of the component, not just a single layer inside it. - */ - -export const figmaDesignUrl = (url: string) => { - const accessToken = process.env.STORYBOOK_FIGMA_ACCESS_TOKEN - - const designConfig = accessToken - ? { type: 'figspec', url, accessToken } - : { type: 'figma', url } - - return { - design: designConfig, - } -} diff --git a/services/web/.storybook/utils/with-split-tests.tsx b/services/web/.storybook/utils/with-split-tests.tsx deleted file mode 100644 index 80a7690891..0000000000 --- a/services/web/.storybook/utils/with-split-tests.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import type { Meta } from '@storybook/react-webpack5' -import _ from 'lodash' -import { SplitTestContext } from '@/shared/context/split-test-context' - -export const defaultSplitTestsArgTypes = { - // to be able to use this utility, you need to add the argTypes for each split test in this object - // Check the original implementation for an example: https://github.com/overleaf/internal/pull/17809 -} - -export const withSplitTests = ( - story: Meta, - splitTests: (keyof ArgTypes)[] = [], - /** @deprecated For demo purposes only. Add actual split tests in defaultSplitTestsArgTypes */ - _splitTestsArgTypes?: ArgTypes -): Meta => { - const splitTestsArgTypes = _splitTestsArgTypes ?? defaultSplitTestsArgTypes - return { - ...story, - argTypes: { ...story.argTypes, ..._.pick(splitTestsArgTypes, splitTests) }, - decorators: [ - (Story, { args }) => { - const splitTestVariants = _.pick(args, splitTests) - const value = { splitTestVariants, splitTestInfo: {} } - return ( - - - - ) - }, - ...(story.decorators - ? Array.isArray(story.decorators) - ? story.decorators - : [story.decorators] - : []), - ], - } -} diff --git a/services/web/.stylelintrc.json b/services/web/.stylelintrc.json deleted file mode 100644 index 83ca4692de..0000000000 --- a/services/web/.stylelintrc.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "extends": ["stylelint-config-standard-scss"], - "plugins": [ - "./stylelint-rules/no-themed-vars-in-root.mjs", - "./stylelint-rules/themed-custom-property-suffix.mjs" - ], - "rules": { - "function-url-quotes": null, - "no-descending-specificity": null, - "scss/at-extend-no-missing-placeholder": null, - "scss/operator-no-newline-after": null, - "property-no-vendor-prefix": [true, { "ignoreProperties": ["mask-image"] }], - "overleaf/no-themed-vars-in-root": true - }, - "overrides": [ - { - "files": ["frontend/stylesheets/abstracts/themes-common-variables.scss"], - "rules": { - "overleaf/themed-custom-property-suffix": true - } - } - ] -} diff --git a/services/web/.vscode/settings.json b/services/web/.vscode/settings.json deleted file mode 100644 index d8655e6ffb..0000000000 --- a/services/web/.vscode/settings.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "files.exclude": { - "node_modules": true, - "data": true - }, - "cSpell.words": ["docstore", "Tpds", "Writefull"] -} diff --git a/services/web/Dockerfile b/services/web/Dockerfile deleted file mode 100644 index 47ed96ed0b..0000000000 --- a/services/web/Dockerfile +++ /dev/null @@ -1,150 +0,0 @@ -# This file was auto-generated, do not edit it directly. -# Instead run internal$ services/web/build_scripts - -FROM node:24.14.1 AS base - -# Corepack setup, shared between all the images. -ENV PATH="/overleaf/node_modules/.bin:$PATH" -ENV COREPACK_HOME=/opt/corepack -RUN corepack enable && corepack install -g yarn@4.14.1 -ENV COREPACK_ENABLE_NETWORK=0 - -WORKDIR /overleaf/services/web - -# Google Cloud Storage needs a writable $HOME/.config for resumable uploads -# (see https://googleapis.dev/nodejs/storage/latest/File.html#createWriteStream) -RUN mkdir /home/node/.config && chown node:node /home/node/.config - -RUN mkdir -p /overleaf/services/web/data/dumpFolder \ - && mkdir -p /overleaf/services/web/data/logs \ - && mkdir -p /overleaf/services/web/data/pdf \ - && mkdir -p /overleaf/services/web/data/uploads \ - && mkdir -p /overleaf/services/web/data/zippedProjects \ - && mkdir -p /overleaf/services/web/data/projectHistories \ - && chmod -R 0755 /overleaf/services/web/data \ - && chown -R node:node /overleaf/services/web/data - -# Add intermediate certificate for prism.optica.org -COPY services/web/certs/DigiCertGlobalG2TLSRSASHA2562020CA1-1.crt.pem /usr/local/share/ca-certificates/DigiCertGlobalG2TLSRSASHA2562020CA1-1.crt -RUN update-ca-certificates - -# the deps image is used for caching yarn workspaces focus -FROM base AS deps-prod - -# Pyodide wheel bundle (~370 MB). Version + SHA-256 are pinned in the fetch -# script; keep that in sync with the pyodide dep in services/web/package.json. -COPY services/web/scripts/fetch-pyodide-packages.mjs /overleaf/services/web/scripts/fetch-pyodide-packages.mjs -RUN cd /overleaf/services/web && node scripts/fetch-pyodide-packages.mjs - -COPY package.json yarn.lock .yarnrc.yml /overleaf/ -COPY libraries/access-token-encryptor/package.json /overleaf/libraries/access-token-encryptor/package.json -COPY libraries/eslint-plugin/package.json /overleaf/libraries/eslint-plugin/package.json -COPY libraries/fetch-utils/package.json /overleaf/libraries/fetch-utils/package.json -COPY libraries/logger/package.json /overleaf/libraries/logger/package.json -COPY libraries/metrics/package.json /overleaf/libraries/metrics/package.json -COPY libraries/mongo-utils/package.json /overleaf/libraries/mongo-utils/package.json -COPY libraries/o-error/package.json /overleaf/libraries/o-error/package.json -COPY libraries/object-persistor/package.json /overleaf/libraries/object-persistor/package.json -COPY libraries/overleaf-editor-core/package.json /overleaf/libraries/overleaf-editor-core/package.json -COPY libraries/promise-utils/package.json /overleaf/libraries/promise-utils/package.json -COPY libraries/ranges-tracker/package.json /overleaf/libraries/ranges-tracker/package.json -COPY libraries/redis-wrapper/package.json /overleaf/libraries/redis-wrapper/package.json -COPY libraries/settings/package.json /overleaf/libraries/settings/package.json -COPY libraries/stream-utils/package.json /overleaf/libraries/stream-utils/package.json -COPY libraries/validation-tools/package.json /overleaf/libraries/validation-tools/package.json -COPY services/web/package.json /overleaf/services/web/package.json -COPY tools/migrations/package.json /overleaf/tools/migrations/package.json -COPY .yarn/patches/ /overleaf/.yarn/patches/ - -RUN cd /overleaf && NODE_ENV=production yarn workspaces focus --production @overleaf/web - -FROM deps-prod AS deps - -ENV CYPRESS_INSTALL_BINARY=0 - -COPY tsconfig.backend.json /overleaf/ -RUN cd /overleaf && yarn workspaces focus @overleaf/web overleaf - - -# the dev is suitable for running tests -FROM deps AS dev - -ARG SENTRY_RELEASE -ENV SENTRY_RELEASE=$SENTRY_RELEASE -COPY libraries/access-token-encryptor/ /overleaf/libraries/access-token-encryptor/ -COPY libraries/eslint-plugin/ /overleaf/libraries/eslint-plugin/ -COPY libraries/fetch-utils/ /overleaf/libraries/fetch-utils/ -COPY libraries/logger/ /overleaf/libraries/logger/ -COPY libraries/metrics/ /overleaf/libraries/metrics/ -COPY libraries/mongo-utils/ /overleaf/libraries/mongo-utils/ -COPY libraries/o-error/ /overleaf/libraries/o-error/ -COPY libraries/object-persistor/ /overleaf/libraries/object-persistor/ -COPY libraries/overleaf-editor-core/ /overleaf/libraries/overleaf-editor-core/ -COPY libraries/promise-utils/ /overleaf/libraries/promise-utils/ -COPY libraries/ranges-tracker/ /overleaf/libraries/ranges-tracker/ -COPY libraries/redis-wrapper/ /overleaf/libraries/redis-wrapper/ -COPY libraries/settings/ /overleaf/libraries/settings/ -COPY libraries/stream-utils/ /overleaf/libraries/stream-utils/ -COPY libraries/validation-tools/ /overleaf/libraries/validation-tools/ -COPY services/web/ /overleaf/services/web/ -COPY tools/migrations/ /overleaf/tools/migrations/ - -# Build the latex parser -RUN cd /overleaf/services/web && yarn run 'lezer-latex:generate' - -# The module folders are owned as root. The node user cannot create them. Create them in advance. -RUN cd /overleaf/services/web && make create_module_Makefiles - -USER node - - -# the webpack image has deps+src+webpack artifacts -FROM dev AS webpack -USER root -RUN OVERLEAF_CONFIG=/overleaf/services/web/config/settings.webpack.js nice yarn run webpack:production - - -# intermediate image for removing source maps ahead of copying into final production image -FROM webpack AS webpack-no-sourcemaps -RUN nice find /overleaf/services/web/public -name '*.js.map' -delete - - -# copy source code and precompile pug images -FROM deps-prod AS pug -COPY libraries/access-token-encryptor/ /overleaf/libraries/access-token-encryptor/ -COPY libraries/eslint-plugin/ /overleaf/libraries/eslint-plugin/ -COPY libraries/fetch-utils/ /overleaf/libraries/fetch-utils/ -COPY libraries/logger/ /overleaf/libraries/logger/ -COPY libraries/metrics/ /overleaf/libraries/metrics/ -COPY libraries/mongo-utils/ /overleaf/libraries/mongo-utils/ -COPY libraries/o-error/ /overleaf/libraries/o-error/ -COPY libraries/object-persistor/ /overleaf/libraries/object-persistor/ -COPY libraries/overleaf-editor-core/ /overleaf/libraries/overleaf-editor-core/ -COPY libraries/promise-utils/ /overleaf/libraries/promise-utils/ -COPY libraries/ranges-tracker/ /overleaf/libraries/ranges-tracker/ -COPY libraries/redis-wrapper/ /overleaf/libraries/redis-wrapper/ -COPY libraries/settings/ /overleaf/libraries/settings/ -COPY libraries/stream-utils/ /overleaf/libraries/stream-utils/ -COPY libraries/validation-tools/ /overleaf/libraries/validation-tools/ -COPY services/web/ /overleaf/services/web/ -COPY tools/migrations/ /overleaf/tools/migrations/ - -# Omit Server Pro/CE specific scripts from SaaS image -RUN rm /overleaf/services/web/modules/server-ce-scripts -rf -RUN OVERLEAF_CONFIG=/overleaf/services/web/config/settings.overrides.saas.js nice yarn run precompile-pug - - -# the web image with only production dependencies but no webpack production build, for development -FROM pug AS app-only -USER node -CMD ["node", "--expose-gc", "app.mjs"] - - -# the final production image, with webpack production build but without source maps -FROM pug AS app -ARG SENTRY_RELEASE -ENV SENTRY_RELEASE=$SENTRY_RELEASE -ENV NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/DigiCertGlobalG2TLSRSASHA2562020CA1-1.crt -COPY --from=webpack-no-sourcemaps /overleaf/services/web/public /overleaf/services/web/public -USER node -CMD ["node", "--expose-gc", "app.mjs"] diff --git a/services/web/Dockerfile.frontend b/services/web/Dockerfile.frontend deleted file mode 100644 index 39614e412d..0000000000 --- a/services/web/Dockerfile.frontend +++ /dev/null @@ -1,6 +0,0 @@ -FROM node:24.14.1 - -# Install Google Chrome -RUN wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - -RUN sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' -RUN apt-get update && apt-get install -y google-chrome-stable diff --git a/services/web/Dockerfile.frontend.ci b/services/web/Dockerfile.frontend.ci deleted file mode 100644 index 463cc45a0c..0000000000 --- a/services/web/Dockerfile.frontend.ci +++ /dev/null @@ -1,11 +0,0 @@ -ARG PROJECT_NAME -ARG BRANCH_NAME_TAG_SAFE -ARG BUILD_NUMBER - -FROM ci/$PROJECT_NAME:$BRANCH_NAME_TAG_SAFE-$BUILD_NUMBER - -USER root - -RUN wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - && \ - echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list && \ - apt-get update && apt-get install -y google-chrome-stable diff --git a/services/web/Makefile b/services/web/Makefile deleted file mode 100644 index fe18056ca0..0000000000 --- a/services/web/Makefile +++ /dev/null @@ -1,802 +0,0 @@ -DOCKER_COMPOSE_FLAGS ?= -f docker-compose.yml -export RETRIES ?= $(if $(CI),3,0) - -export BUILD_NUMBER ?= local -ifeq ($(BRANCH_NAME),) - export BRANCH_NAME := $(shell git rev-parse --abbrev-ref HEAD) -endif -export BRANCH_NAME_TAG_SAFE := $(shell echo "$(BRANCH_NAME)" | sed 's/\//\-\-/g' | cut -c1-96) - -.PHONY: print-branch-tag-safe -print-branch-tag-safe: - @echo $(BRANCH_NAME_TAG_SAFE) - -ifeq ($(COMMIT_SHA),) - export COMMIT_SHA := $(shell git rev-parse HEAD) -endif -export PROJECT_NAME = web -BUILD_DIR_NAME := web -PWD := $(shell pwd) -ifeq ($(MONOREPO),) - export MONOREPO := $(shell cd ../../ && pwd) -endif - -export OVERLEAF_CONFIG ?= /overleaf/services/web/test/acceptance/config/settings.test.saas.js -export BASE_CONFIG ?= ${OVERLEAF_CONFIG} - -CFG_SAAS=/overleaf/services/web/test/acceptance/config/settings.test.saas.js -CFG_SERVER_CE=/overleaf/services/web/test/acceptance/config/settings.test.server-ce.js -CFG_SERVER_PRO=/overleaf/services/web/test/acceptance/config/settings.test.server-pro.js - -export MOCHA_GREP ?= -DOCKER_COMPOSE := docker compose ${DOCKER_COMPOSE_FLAGS} - -# Common flags for docker compose run. -# --rm Tear down test runner container -# --build Make sure that we use the latest base image -# --quiet-build Hide build output -ifeq ($(CI),true) - DC_RUN_FLAGS := --rm -else - DC_RUN_FLAGS := --rm --build --quiet-build -endif - -MODULE_DIRS := $(shell find modules -mindepth 1 -maxdepth 1 -type d -not -name '.git' ) -MODULE_MAKEFILES := $(MODULE_DIRS:=/Makefile) -MODULE_NAME=$(shell basename $(MODULE)) - -create_module_Makefiles: $(MODULE_MAKEFILES) -$(MODULE_MAKEFILES): Makefile.module - cp Makefile.module $@ || diff Makefile.module $@ - -MODULE_APP_SRC_DIRS := $(shell find modules -mindepth 3 -maxdepth 3 -type d -path '*/app/src') -MODULE_TSCONFIGS := $(MODULE_APP_SRC_DIRS:=/tsconfig.json) - -create_module_tsconfigs: $(MODULE_TSCONFIGS) -$(MODULE_TSCONFIGS): tsconfig.module.json - cp tsconfig.module.json $@ || diff tsconfig.module.json $@ - -# -# Clean -# - -SHARD_PROJECT_NAMES = \ - unit_test_$(BUILD_DIR_NAME) \ - unit_test_all_$(BUILD_DIR_NAME) \ - unit_test_esm_$(BUILD_DIR_NAME) \ - unit_test_esm_watch_$(BUILD_DIR_NAME) \ - unit_test_mocha_$(BUILD_DIR_NAME) \ - unit_test_parallel_$(BUILD_DIR_NAME) \ - unit_test_parallel_make_$(BUILD_DIR_NAME) \ - acceptance_test_saas_$(BUILD_DIR_NAME) \ - acceptance_test_server_ce_$(BUILD_DIR_NAME) \ - acceptance_test_server_pro_$(BUILD_DIR_NAME) \ - acceptance_test_modules_merged_saas_$(BUILD_DIR_NAME) \ - acceptance_test_modules_merged_saas_1_$(BUILD_DIR_NAME) \ - acceptance_test_modules_merged_saas_2_$(BUILD_DIR_NAME) \ - acceptance_test_modules_merged_saas_3_$(BUILD_DIR_NAME) \ - acceptance_test_modules_merged_saas_4_$(BUILD_DIR_NAME) \ - acceptance_test_modules_merged_saas_5_$(BUILD_DIR_NAME) \ - acceptance_test_modules_merged_server_ce_$(BUILD_DIR_NAME) \ - acceptance_test_modules_merged_server_pro_$(BUILD_DIR_NAME) \ - frontend_test_$(BUILD_DIR_NAME) \ - test_frontend_ct_$(BUILD_DIR_NAME) \ - test_frontend_ct_core_other_$(BUILD_DIR_NAME) \ - test_frontend_ct_core_features_$(BUILD_DIR_NAME) \ - test_frontend_ct_modules_$(BUILD_DIR_NAME) \ - test_frontend_ct_editor_other_$(BUILD_DIR_NAME) \ - test_frontend_ct_editor_visual_$(BUILD_DIR_NAME) \ - tar_$(BUILD_DIR_NAME) \ - -CLEAN_SHARDS=$(addprefix clean/,$(SHARD_PROJECT_NAMES)) -clean: $(CLEAN_SHARDS) - -docker run --rm --volume /dev/shm:/dev/shm --user root $(IMAGE_CI) rm -rf /dev/shm/overleaf - -docker rmi --force $(IMAGE_CI) $(IMAGE_CI)-dev $(IMAGE_CI)-pug $(IMAGE_CI)-webpack $(IMAGE_REPO_FINAL) - -docker compose down --remove-orphans --rmi local --timeout 0 --volumes - -git clean -dfX data/ - -$(CLEAN_SHARDS): clean/%: - -COMPOSE_PROJECT_NAME=$* docker compose down --remove-orphans --rmi local --timeout 0 --volumes - -clean_ci: - $(DOCKER_COMPOSE) down -v -t 0 - docker container list | grep 'days ago' | cut -d ' ' -f 1 - | xargs -r docker container stop - docker image prune -af --filter "until=48h" - docker network prune -f - -# -# Tests -# - -test: test_unit test_acceptance test_frontend test_writefull - -test_module: test_unit_module test_acceptance_module - -# -# Unit tests -# - -test_unit: test_unit_all -test_unit_all: export COMPOSE_PROJECT_NAME=unit_test_all_$(BUILD_DIR_NAME) -test_unit_all: mongo_migrations_for_tests - $(DOCKER_COMPOSE) run $(DC_RUN_FLAGS) test_unit yarn run test:unit:all - -test_unit_all_silent: export COMPOSE_PROJECT_NAME=unit_test_all_$(BUILD_DIR_NAME) -test_unit_all_silent: mongo_migrations_for_tests - $(DOCKER_COMPOSE) run $(DC_RUN_FLAGS) test_unit yarn run test:unit:all:silent - -test_unit_app: export COMPOSE_PROJECT_NAME=unit_test_$(BUILD_DIR_NAME) -test_unit_app: mongo_migrations_for_tests - $(DOCKER_COMPOSE) run --name unit_test_$(BUILD_DIR_NAME) --rm test_unit - -test_unit_parallel: export JUNIT_ROOT_SUITE_NAME = ESM unit tests - parallel -test_unit_parallel: export COMPOSE_PROJECT_NAME=unit_test_parallel_$(BUILD_DIR_NAME) -test_unit_parallel: mongo_migrations_for_tests - $(DOCKER_COMPOSE) run $(DC_RUN_FLAGS) test_unit yarn run test:unit:parallel - -test_unit_sequential: export JUNIT_ROOT_SUITE_NAME = ESM unit tests - sequential -test_unit_sequential: export COMPOSE_PROJECT_NAME=unit_test_sequential_$(BUILD_DIR_NAME) -test_unit_sequential: mongo_migrations_for_tests - $(DOCKER_COMPOSE) run $(DC_RUN_FLAGS) test_unit yarn run test:unit:sequential - -test_unit_watch: export COMPOSE_PROJECT_NAME=unit_test_watch_$(BUILD_DIR_NAME) -test_unit_watch: mongo_migrations_for_tests - $(DOCKER_COMPOSE) run $(DC_RUN_FLAGS) test_unit yarn run test:unit:watch - -TEST_SUITES = $(sort $(filter-out \ - $(wildcard test/unit/src/helpers/*), \ - $(wildcard test/unit/src/*/*))) - -MOCHA_CMD_LINE = \ - mocha \ - --exit \ - --file test/unit/bootstrap.js \ - --grep="${MOCHA_GREP}" \ - --reporter spec \ - --timeout 25000 \ - -.PHONY: $(TEST_SUITES) -$(TEST_SUITES): - $(MOCHA_CMD_LINE) $@ - -J ?= 1 -test_unit_app_parallel_gnu_make: $(TEST_SUITES) -test_unit_app_parallel_gnu_make_docker: export COMPOSE_PROJECT_NAME = \ - unit_test_parallel_make_$(BUILD_DIR_NAME) -test_unit_app_parallel_gnu_make_docker: mongo_migrations_for_tests - $(DOCKER_COMPOSE) run $(DC_RUN_FLAGS) test_unit \ - make test_unit_app_parallel_gnu_make --output-sync -j $(J) - -TEST_UNIT_MODULES = $(MODULE_DIRS:=/test_unit) -$(TEST_UNIT_MODULES): %/test_unit: %/Makefile -test_unit_modules: mongo_migrations_for_tests $(TEST_UNIT_MODULES) - -test_unit_module: mongo_migrations_for_tests - $(MAKE) modules/$(MODULE_NAME)/test_unit - -mongo_migrations_for_tests: - $(DOCKER_COMPOSE) run $(DC_RUN_FLAGS) --workdir /overleaf/tools/migrations test_unit yarn run migrations migrate -t 'saas & !nonblocking' - -# -# Frontend tests -# - -test_frontend_jsdom: export JUNIT_ROOT_SUITE_NAME = JSDOM frontend tests -test_frontend_jsdom: - COMPOSE_PROJECT_NAME=frontend_test_$(BUILD_DIR_NAME) $(DOCKER_COMPOSE) run $(DC_RUN_FLAGS) test_frontend_jsdom - COMPOSE_PROJECT_NAME=frontend_test_$(BUILD_DIR_NAME) $(DOCKER_COMPOSE) down -v -t 0 - -test_frontend: test_frontend_jsdom test_frontend_ct - -# -# Frontend component tests in Cypress -# -# Local development: use $ make test_frontend_ct -# -TEST_FRONTEND_CT_VARIANTS = \ - test_frontend_ct \ - test_frontend_ct_core_other \ - test_frontend_ct_core_features \ - test_frontend_ct_modules \ - test_frontend_ct_editor_other \ - test_frontend_ct_editor_visual \ - -TEST_FRONTEND_CT_TARGETS = \ - $(TEST_FRONTEND_CT_VARIANTS) \ - build_test_frontend_ct \ - test_frontend_ct_ui \ - -$(TEST_FRONTEND_CT_TARGETS): export USER_UID=$(shell id -u) -$(TEST_FRONTEND_CT_TARGETS): export USER_GID=$(shell id -g) - -test_frontend_ct_ui: - docker compose -f docker-compose.cypress.yml run $(DC_RUN_FLAGS) cypress run cypress:open-ct -- --browser chrome - -# -# Writefull tests -# - -test_writefull: export JUNIT_ROOT_SUITE_NAME = Writefull tests -test_writefull: - COMPOSE_PROJECT_NAME=writefull_test_$(BUILD_DIR_NAME) $(DOCKER_COMPOSE) run $(DC_RUN_FLAGS) test_writefull - COMPOSE_PROJECT_NAME=writefull_test_$(BUILD_DIR_NAME) $(DOCKER_COMPOSE) down -v -t 0 - -# Note: The below cypress targets are for CI only -build_test_frontend_ct: - $(DOCKER_COMPOSE) build test_frontend_ct - docker run --rm --volume /dev/shm:/dev/shm --user root $(IMAGE_CI) bash -ec 'for path in /overleaf/services/web/cypress/results /overleaf/services/web/cypress/support /overleaf/services/web/.cache; do mkdir -p $$path; chown -R node:node $$path; done && tar -cC / overleaf | tar -xC /dev/shm' - -test_frontend_ct_core_other: export CYPRESS_DOWNLOADS=./cypress/downloads/core -test_frontend_ct_core_other: export CYPRESS_RESULTS=./cypress/results/core -test_frontend_ct_core_other: export CYPRESS_SPEC_PATTERN=./test/frontend/**/*.spec.{js,jsx,ts,tsx} -test_frontend_ct_core_other: export CYPRESS_EXCLUDE_SPEC_PATTERN=./test/frontend/features/**/*.spec.{js,jsx,ts,tsx} - -test_frontend_ct_core_features: export CYPRESS_DOWNLOADS=./cypress/downloads/core -test_frontend_ct_core_features: export CYPRESS_RESULTS=./cypress/results/core -test_frontend_ct_core_features: export CYPRESS_SPEC_PATTERN=./test/frontend/features/**/*.spec.{js,jsx,ts,tsx} -test_frontend_ct_core_features: export CYPRESS_EXCLUDE_SPEC_PATTERN=./test/frontend/features/source-editor/**/*.spec.{js,jsx,ts,tsx} - -test_frontend_ct_modules: export CYPRESS_DOWNLOADS=./cypress/downloads/modules -test_frontend_ct_modules: export CYPRESS_RESULTS=./cypress/results/modules -test_frontend_ct_modules: export CYPRESS_SPEC_PATTERN=./modules/**/test/frontend/**/*.spec.{js,jsx,ts,tsx} - -test_frontend_ct_editor_other: export CYPRESS_DOWNLOADS=./cypress/downloads/editor_other -test_frontend_ct_editor_other: export CYPRESS_RESULTS=./cypress/results/editor_other -test_frontend_ct_editor_other: export CYPRESS_SPEC_PATTERN=./test/frontend/features/source-editor/**/*.spec.{js,jsx,ts,tsx} -test_frontend_ct_editor_other: export CYPRESS_EXCLUDE_SPEC_PATTERN=./test/frontend/features/source-editor/components/codemirror-editor-visual*.spec.{js,jsx,ts,tsx} - -test_frontend_ct_editor_visual: export CYPRESS_DOWNLOADS=./cypress/downloads/editor_visual -test_frontend_ct_editor_visual: export CYPRESS_RESULTS=./cypress/results/editor_visual -test_frontend_ct_editor_visual: export CYPRESS_SPEC_PATTERN=./test/frontend/features/source-editor/components/codemirror-editor-visual*.spec.{js,jsx,ts,tsx} - -$(TEST_FRONTEND_CT_VARIANTS): - COMPOSE_PROJECT_NAME=$@_$(BUILD_DIR_NAME) $(DOCKER_COMPOSE) run $(DC_RUN_FLAGS) test_frontend_ct - COMPOSE_PROJECT_NAME=$@_$(BUILD_DIR_NAME) $(DOCKER_COMPOSE) down -v -t 0 - -# -# Acceptance tests -# - -test_acceptance: test_acceptance_app test_acceptance_modules -test_acceptance_saas: test_acceptance_app_saas test_acceptance_modules_merged_saas -test_acceptance_server_ce: test_acceptance_app_server_ce test_acceptance_modules_merged_server_ce -test_acceptance_server_pro: test_acceptance_app_server_pro test_acceptance_modules_merged_server_pro - -TEST_ACCEPTANCE_APP := \ - test_acceptance_app_saas \ - test_acceptance_app_server_ce \ - test_acceptance_app_server_pro \ - -ifeq ($(DEBUG),true) -TEST_ACCEPTANCE_DEBUG_PORT := -p 127.0.0.1:19999:19999 -TEST_ACCEPTANCE_YARN_SCRIPT := yarn run --silent test:acceptance:app:debug -else -TEST_ACCEPTANCE_DEBUG_PORT := -TEST_ACCEPTANCE_YARN_SCRIPT := yarn run --silent test:acceptance:app -endif - -test_acceptance_app: $(TEST_ACCEPTANCE_APP) -test_acceptance_app_saas: export JUNIT_ROOT_SUITE_NAME = SaaS app acceptance tests -test_acceptance_app_saas: export COMPOSE_PROJECT_NAME=acceptance_test_saas_$(BUILD_DIR_NAME) -test_acceptance_app_saas: export OVERLEAF_CONFIG=$(CFG_SAAS) -test_acceptance_app_server_ce: export JUNIT_ROOT_SUITE_NAME = Server CE app acceptance tests -test_acceptance_app_server_ce: export COMPOSE_PROJECT_NAME=acceptance_test_server_ce_$(BUILD_DIR_NAME) -test_acceptance_app_server_ce: export OVERLEAF_CONFIG=$(CFG_SERVER_CE) -test_acceptance_app_server_pro: export JUNIT_ROOT_SUITE_NAME = Server Pro app acceptance tests -test_acceptance_app_server_pro: export COMPOSE_PROJECT_NAME=acceptance_test_server_pro_$(BUILD_DIR_NAME) -test_acceptance_app_server_pro: export OVERLEAF_CONFIG=$(CFG_SERVER_PRO) - -$(TEST_ACCEPTANCE_APP): - $(DOCKER_COMPOSE) run $(DC_RUN_FLAGS) $(TEST_ACCEPTANCE_DEBUG_PORT) test_acceptance $(TEST_ACCEPTANCE_YARN_SCRIPT) - $(DOCKER_COMPOSE) down -v -t 0 - -# We are using _make magic_ for turning these file-targets into calls to -# sub-Makefiles in the individual modules. -# These sub-Makefiles need to be kept in sync with the template, hence we -# add a dependency on each modules Makefile and cross-link that to the -# template at the very top of this file. -# Example: `web$ make modules/server-ce-scripts/test_acceptance_server_ce` -# Description: Run the acceptance tests of the server-ce-scripts module in an -# Overleaf Community Edition Environment. -# Break down: -# Target: modules/server-ce-scripts/test_acceptance_server_ce -# -> depends on modules/server-ce-scripts/Makefile -# -> add environment variable BASE_CONFIG=$(CFG_SERVER_CE) -# -> BASE_CONFIG=/overleaf/services/web/test/acceptance/config/settings.test.server-ce.js -# -> automatic target: `make -C server-ce-scripts test_acceptance_server_ce` -# -> automatic target: run `make test_acceptance_server_ce` in module -# Target: modules/server-ce-scripts/Makefile -# -> depends on Makefile.module -# -> automatic target: copies the file when changed -TEST_ACCEPTANCE_MODULES = $(MODULE_DIRS:=/test_acceptance) -$(TEST_ACCEPTANCE_MODULES): %/test_acceptance: %/Makefile -$(TEST_ACCEPTANCE_MODULES): modules/%/test_acceptance: - $(MAKE) test_acceptance_module MODULE_NAME=$* - -TEST_ACCEPTANCE_MODULES_SAAS = $(MODULE_DIRS:=/test_acceptance_saas) -$(TEST_ACCEPTANCE_MODULES_SAAS): %/test_acceptance_saas: %/Makefile -$(TEST_ACCEPTANCE_MODULES_SAAS): export BASE_CONFIG = $(CFG_SAAS) - -# This line adds `/test_acceptance_saas` suffix to all items in $(MODULE_DIRS). -TEST_ACCEPTANCE_MODULES_SERVER_CE = $(MODULE_DIRS:=/test_acceptance_server_ce) -# This line adds a dependency on the modules Makefile. -$(TEST_ACCEPTANCE_MODULES_SERVER_CE): %/test_acceptance_server_ce: %/Makefile -# This line adds the environment variable BASE_CONFIG=$(CFG_SERVER_CE) to all -# invocations of `web$ make modules/foo/test_acceptance_server_ce`. -$(TEST_ACCEPTANCE_MODULES_SERVER_CE): export BASE_CONFIG = $(CFG_SERVER_CE) - -TEST_ACCEPTANCE_MODULES_SERVER_PRO = $(MODULE_DIRS:=/test_acceptance_server_pro) -$(TEST_ACCEPTANCE_MODULES_SERVER_PRO): %/test_acceptance_server_pro: %/Makefile -$(TEST_ACCEPTANCE_MODULES_SERVER_PRO): export BASE_CONFIG = $(CFG_SERVER_PRO) - -CLEAN_TEST_ACCEPTANCE_MODULES = $(MODULE_DIRS:=/clean_test_acceptance) -$(CLEAN_TEST_ACCEPTANCE_MODULES): %/clean_test_acceptance: %/Makefile -clean_test_acceptance_modules: $(CLEAN_TEST_ACCEPTANCE_MODULES) -clean_ci: clean_test_acceptance_modules - -test_acceptance_module_noop: - @echo - @echo Module '$(MODULE_NAME)' does not run in ${LABEL}. - @echo - -TEST_ACCEPTANCE_MODULE_MAYBE_IN := \ - test_acceptance_module_maybe_in_saas \ - test_acceptance_module_maybe_in_server_ce \ - test_acceptance_module_maybe_in_server_pro \ - -test_acceptance_module: $(TEST_ACCEPTANCE_MODULE_MAYBE_IN) -test_acceptance_module_maybe_in_saas: export BASE_CONFIG=$(CFG_SAAS) -test_acceptance_module_maybe_in_server_ce: export BASE_CONFIG=$(CFG_SERVER_CE) -test_acceptance_module_maybe_in_server_pro: export BASE_CONFIG=$(CFG_SERVER_PRO) - -# We need to figure out whether the module is loaded in a given environment. -# This information is stored in the (base-)settings. -# We get the full list of modules and check for a matching module entry. -# Either the grep will find and emit the module, or exits with code 1, which -# we handle with a fallback to a noop make target. -# Run the node command in a docker compose container which provides the needed -# yarn dependencies (from disk in dev-env or from the CI image in CI). -# Pick the test_unit service which is very light-weight -- the test_acceptance -# service would start mongo/redis. -$(TEST_ACCEPTANCE_MODULE_MAYBE_IN): test_acceptance_module_maybe_in_%: - $(MAKE) $(shell \ - OVERLEAF_CONFIG=$(BASE_CONFIG) \ - $(DOCKER_COMPOSE) run $(DC_RUN_FLAGS) test_unit \ - yarn node test/acceptance/getModuleTargets test_acceptance_$* \ - | grep -e /$(MODULE_NAME)/ || echo test_acceptance_module_noop LABEL=$* \ - ) - -# See docs for test_acceptance_server_ce how this works. -test_acceptance_module_saas: export BASE_CONFIG = $(CFG_SAAS) -test_acceptance_module_saas: - $(MAKE) modules/$(MODULE_NAME)/test_acceptance_saas - -test_acceptance_module_server_ce: export BASE_CONFIG = $(CFG_SERVER_CE) -test_acceptance_module_server_ce: - $(MAKE) modules/$(MODULE_NAME)/test_acceptance_server_ce - -test_acceptance_module_server_pro: export BASE_CONFIG = $(CFG_SERVER_PRO) -test_acceptance_module_server_pro: - $(MAKE) modules/$(MODULE_NAME)/test_acceptance_server_pro - -# See docs for test_acceptance_server_ce how this works. -TEST_ACCEPTANCE_MODULES_MERGED_INNER = $(MODULE_DIRS:=/test_acceptance_merged_inner) -$(TEST_ACCEPTANCE_MODULES_MERGED_INNER): %/test_acceptance_merged_inner: %/Makefile -test_acceptance_modules_merged_inner: - $(MAKE) $(shell \ - OVERLEAF_CONFIG=$(BASE_CONFIG) \ - yarn node test/acceptance/getModuleTargets test_acceptance_merged_inner \ - ) - -# inner loop for running saas tests in parallel -no_more_targets: - -# If we ever have more than 50 modules, we need to add _6 targets to all the places and have it START at 51. -test_acceptance_modules_merged_inner_1: export START=1 -test_acceptance_modules_merged_inner_2: export START=11 -test_acceptance_modules_merged_inner_3: export START=21 -test_acceptance_modules_merged_inner_4: export START=31 -test_acceptance_modules_merged_inner_5: export START=41 -TEST_ACCEPTANCE_MODULES_MERGED_INNER_SPLIT = \ - test_acceptance_modules_merged_inner_1 \ - test_acceptance_modules_merged_inner_2 \ - test_acceptance_modules_merged_inner_3 \ - test_acceptance_modules_merged_inner_4 \ - test_acceptance_modules_merged_inner_5 \ - -# The node script prints one module per line. -# Using tail and head we skip over the first n=START entries and print the last 10. -# Finally we check with grep for any targets in a batch and print a fallback if none were found. -$(TEST_ACCEPTANCE_MODULES_MERGED_INNER_SPLIT): - $(MAKE) $(shell \ - OVERLEAF_CONFIG=$(BASE_CONFIG) \ - yarn node test/acceptance/getModuleTargets test_acceptance_merged_inner \ - | tail -n+$(START) | head -n 10 \ - | grep -e . || echo no_more_targets \ - ) - -# See docs for test_acceptance_server_ce how this works. -test_acceptance_modules_merged_saas: export JUNIT_ROOT_SUITE_NAME = SaaS modules acceptance tests -test_acceptance_modules_merged_saas: export COMPOSE_PROJECT_NAME = \ - acceptance_test_modules_merged_saas_$(BUILD_DIR_NAME) -test_acceptance_modules_merged_saas: export BASE_CONFIG = $(CFG_SAAS) - -test_acceptance_modules_merged_server_ce: export JUNIT_ROOT_SUITE_NAME = Server CE modules acceptance tests -test_acceptance_modules_merged_server_ce: export COMPOSE_PROJECT_NAME = \ - acceptance_test_modules_merged_server_ce_$(BUILD_DIR_NAME) -test_acceptance_modules_merged_server_ce: export BASE_CONFIG = $(CFG_SERVER_CE) - -test_acceptance_modules_merged_server_pro: export JUNIT_ROOT_SUITE_NAME = Server Pro modules acceptance tests -test_acceptance_modules_merged_server_pro: export COMPOSE_PROJECT_NAME = \ - acceptance_test_modules_merged_server_pro_$(BUILD_DIR_NAME) -test_acceptance_modules_merged_server_pro: export BASE_CONFIG = $(CFG_SERVER_PRO) - -# All these variants run the same command. -# Each target has a different set of environment defined above. -TEST_ACCEPTANCE_MODULES_MERGED_VARIANTS = \ - test_acceptance_modules_merged_saas \ - test_acceptance_modules_merged_server_ce \ - test_acceptance_modules_merged_server_pro \ - -$(TEST_ACCEPTANCE_MODULES_MERGED_VARIANTS): - $(DOCKER_COMPOSE) run $(DC_RUN_FLAGS) test_acceptance yarn exec make test_acceptance_modules_merged_inner - $(DOCKER_COMPOSE) down -v -t 0 - -# outer loop for running saas tests in parallel -TEST_ACCEPTANCE_MODULES_MERGED_SPLIT_SAAS = \ - test_acceptance_modules_merged_saas_1 \ - test_acceptance_modules_merged_saas_2 \ - test_acceptance_modules_merged_saas_3 \ - test_acceptance_modules_merged_saas_4 \ - test_acceptance_modules_merged_saas_5 \ - -test_acceptance_modules_merged_saas_1: export COMPOSE_PROJECT_NAME = \ - acceptance_test_modules_merged_saas_1_$(BUILD_DIR_NAME) -test_acceptance_modules_merged_saas_2: export COMPOSE_PROJECT_NAME = \ - acceptance_test_modules_merged_saas_2_$(BUILD_DIR_NAME) -test_acceptance_modules_merged_saas_3: export COMPOSE_PROJECT_NAME = \ - acceptance_test_modules_merged_saas_3_$(BUILD_DIR_NAME) -test_acceptance_modules_merged_saas_4: export COMPOSE_PROJECT_NAME = \ - acceptance_test_modules_merged_saas_4_$(BUILD_DIR_NAME) -test_acceptance_modules_merged_saas_5: export COMPOSE_PROJECT_NAME = \ - acceptance_test_modules_merged_saas_5_$(BUILD_DIR_NAME) -$(TEST_ACCEPTANCE_MODULES_MERGED_SPLIT_SAAS): export BASE_CONFIG = $(CFG_SAAS) -$(TEST_ACCEPTANCE_MODULES_MERGED_SPLIT_SAAS): export JUNIT_ROOT_SUITE_NAME = SaaS modules acceptance tests - -$(TEST_ACCEPTANCE_MODULES_MERGED_SPLIT_SAAS): test_acceptance_modules_merged_saas_%: - $(DOCKER_COMPOSE) run $(DC_RUN_FLAGS) test_acceptance yarn exec make test_acceptance_modules_merged_inner_$* - $(DOCKER_COMPOSE) down -v -t 0 - -test_acceptance_modules: $(TEST_ACCEPTANCE_MODULES_MERGED_VARIANTS) - -# -# CI tests -# - -rebase_coverage_reports: - sed -i 's_path="/overleaf/_path="_' data/coverage/*/clover.xml - -# Create the bundle with full paths inside the monorepo to make it easier to organize them later. -bundle_coverage_reports: - cd ../../ && tar -czf services/web/data/coverage.tar.gz services/web/data/coverage/*/clover.xml - -ci: - MOCHA_ARGS="--reporter tap" \ - $(MAKE) test - -# -# Lint & format -# - -# Run the linting commands in the scope of the monorepo. -# Eslint and prettier (plus some configs) are on the root. -RUN_LINTING_CI_MONOREPO = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/web/data/reports:/overleaf/services/web/data/reports --volume $(MONOREPO)/.cache/:/overleaf/.cache/ -w /overleaf $(IMAGE_CI) yarn run --silent -RUN_LINTING = ../../bin/run -w /overleaf/services/$(PROJECT_NAME) monorepo yarn run --silent -RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent - -ORG_PATH = /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin -RUN_LINT_FORMAT ?= \ - docker run --rm --env BRANCH_NAME --env CI --env COMMIT_SHA --env MONOREPO --env PATH=/overleaf/node_modules/.bin:$(ORG_PATH) --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume ${PWD}/data/reports:/overleaf/services/web/data/reports --volume $(MONOREPO)/.cache/:/overleaf/.cache/ ${IMAGE_CI} - -NODE_MODULES_PATH := ${PATH}:${PWD}/node_modules/.bin:/overleaf/services/web/node_modules/.bin -WITH_NODE_MODULES_PATH = \ - format_backend \ - format_frontend \ - format_misc \ - format_styles \ - format_test_app_unit \ - format_test_app_rest \ - format_test_modules \ - $(TEST_SUITES) \ - -$(WITH_NODE_MODULES_PATH): export PATH=$(NODE_MODULES_PATH) - -WITH_CACHE_FOLDER = \ - lint_eslint \ - lint_stylelint \ - lint_in_docker \ - format_js \ - format_ci \ - format_fix \ - format_styles \ - format_styles_fix \ - format_pug \ - format_pug_fix \ - format_jenkins \ - format_jenkins_fix \ - format_in_docker \ - -$(WITH_CACHE_FOLDER): ../../.cache/prettier/ -../../.cache/prettier/: - mkdir -p $@ - -$(WITH_CACHE_FOLDER): ../../.cache/eslint/ -../../.cache/eslint/: - mkdir -p $@ - -$(WITH_CACHE_FOLDER): ../../.cache/stylelint/ -../../.cache/stylelint/: - mkdir -p $@ - -lint: lint_eslint -lint_eslint: -ifeq ($(CI),true) - -yarn run lint --format json --output-file data/reports/eslint.json - sed -i 's_"filePath":"/overleaf_"filePath":"$(MONOREPO)_g' data/reports/eslint.json -else - $(RUN_LINTING) lint -endif - -lint_fix: lint_eslint_fix -lint_eslint_fix: - $(RUN_LINTING) lint:fix - -lint: lint_stylelint -lint_stylelint: -ifeq ($(CI),true) - -yarn run lint:styles --formatter json --output-file data/reports/stylelint.json - sed -i 's_"source":"/overleaf_"source":"$(MONOREPO)_g' data/reports/stylelint.json -else - $(RUN_LINTING) lint:styles -endif - -lint_fix: lint_stylelint_fix -lint_stylelint_fix: - $(RUN_LINTING) lint:styles:fix - -lint: lint_pug -lint_pug: - bin/lint_pug_templates - -lint: lint_locales -lint_locales: - bin/lint_locales - -lint: check_extracted_translations -check_extracted_translations: - bin/check_extracted_translations - -cleanup_unused_locales: - node scripts/translations/cleanupUnusedLocales.js - -lint: lint_flag_res_send_usage -lint_flag_res_send_usage: - bin/lint_flag_res_send_usage - -lint: lint_overleafModuleImports -lint_overleafModuleImports: - node scripts/check_overleafModuleImports.mjs - -lint: typecheck_frontend -typecheck_frontend: -ifeq ($(CI),true) - yarn run --silent type-check -else - $(RUN_LINTING) type-check -endif - -lint: typecheck_backend -typecheck_backend: -ifeq ($(CI),true) - yarn run --silent type-check:backend -else - $(RUN_LINTING) type-check:backend -endif - -lint_in_docker: - $(RUN_LINT_FORMAT) make lint -j2 --output-sync - -format: format_js -format_js: - $(RUN_LINTING_MONOREPO) format services/web - -format_ci: - $(RUN_LINTING_CI_MONOREPO) format services/web - -format_fix: - $(RUN_LINTING_MONOREPO) format:fix services/web - -format_styles: - $(RUN_LINTING_MONOREPO) format:styles - -format_styles_fix: - $(RUN_LINTING_MONOREPO) format:styles:fix - -format_pug: - $(RUN_LINTING_MONOREPO) format:pug - -format_pug_fix: - $(RUN_LINTING_MONOREPO) format:pug:fix - -format_jenkins: - $(RUN_LINTING_MONOREPO) format:jenkins - -format_jenkins_fix: - $(RUN_LINTING_MONOREPO) format:jenkins:fix - -format_in_docker: - $(RUN_LINT_FORMAT) bash -c 'cd /overleaf && yarn run format services/web' - -SHELLCHECK_OPTS = \ - --shell=bash \ - --external-sources -SHELLCHECK_COLOR := $(if $(CI),--color=never,--color) -SHELLCHECK_FILES := { git ls-files "*.sh" -z; git grep -Plz "\A\#\!.*bash"; } | sort -zu - -shellcheck: - @$(SHELLCHECK_FILES) | xargs -0 -r docker run --rm -v $(PWD):/mnt -w /mnt \ - koalaman/shellcheck:stable $(SHELLCHECK_OPTS) $(SHELLCHECK_COLOR) - -shellcheck_fix: - @$(SHELLCHECK_FILES) | while IFS= read -r -d '' file; do \ - diff=$$(docker run --rm -v $(PWD):/mnt -w /mnt koalaman/shellcheck:stable $(SHELLCHECK_OPTS) --format=diff "$$file" 2>/dev/null); \ - if [ -n "$$diff" ] && ! echo "$$diff" | patch -p1 >/dev/null 2>&1; then echo "\033[31m$$file\033[0m"; \ - elif [ -n "$$diff" ]; then echo "$$file"; \ - else echo "\033[2m$$file\033[0m"; fi \ - done - -# -# Build & publish -# - -IMAGE_CI ?= ci/$(PROJECT_NAME):$(BRANCH_NAME_TAG_SAFE)-$(BUILD_NUMBER) -IMAGE_REPO ?= us-east1-docker.pkg.dev/overleaf-ops/ol-docker/$(PROJECT_NAME) -IMAGE_REPO_FINAL ?= $(IMAGE_REPO):$(BRANCH_NAME_TAG_SAFE)-$(BUILD_NUMBER) -# A shared scratch tag that is used by all the pipelines for pushing the layers of the production image ASAP from a parallel step. -# We do not want to make the image available before all the tests have passed. -# Using a single tag avoids generating cruft in our docker repository / AR. -IMAGE_SCRATCH ?= $(IMAGE_REPO):do-not-use-this-tag-for-deploys--it-is-used-for-early-pushes-in-ci -IMAGE_CACHE ?= $(IMAGE_REPO):cache-$(shell cat \ - $(MONOREPO)/package.json \ - $(MONOREPO)/yarn.lock \ - $(MONOREPO)/libraries/access-token-encryptor/package.json \ - $(MONOREPO)/libraries/eslint-plugin/package.json \ - $(MONOREPO)/libraries/fetch-utils/package.json \ - $(MONOREPO)/libraries/logger/package.json \ - $(MONOREPO)/libraries/metrics/package.json \ - $(MONOREPO)/libraries/mongo-utils/package.json \ - $(MONOREPO)/libraries/o-error/package.json \ - $(MONOREPO)/libraries/object-persistor/package.json \ - $(MONOREPO)/libraries/overleaf-editor-core/package.json \ - $(MONOREPO)/libraries/promise-utils/package.json \ - $(MONOREPO)/libraries/ranges-tracker/package.json \ - $(MONOREPO)/libraries/redis-wrapper/package.json \ - $(MONOREPO)/libraries/settings/package.json \ - $(MONOREPO)/libraries/stream-utils/package.json \ - $(MONOREPO)/libraries/validation-tools/package.json \ - $(MONOREPO)/services/web/package.json \ - $(MONOREPO)/services/web/scripts/fetch-pyodide-packages.mjs \ - $(MONOREPO)/patches/* \ -| sha256sum | cut -d '-' -f1) - -export SENTRY_RELEASE ?= ${COMMIT_SHA} - -build_deps: - docker build --pull \ - --build-arg BUILDKIT_INLINE_CACHE=1 \ - --cache-from $(IMAGE_CACHE) \ - --tag $(IMAGE_CACHE) \ - --target deps \ - --file Dockerfile \ - ../.. - -build_dev: - docker build \ - --build-arg SENTRY_RELEASE \ - --tag $(IMAGE_CI) \ - --tag $(IMAGE_CI)-dev \ - --target dev \ - --file Dockerfile \ - ../.. - -build_webpack: - $(MAKE) build_webpack_once \ - || $(MAKE) build_webpack_once - -build_webpack_once: - docker build \ - --build-arg SENTRY_RELEASE \ - --tag $(IMAGE_CI)-webpack \ - --target webpack \ - --file Dockerfile \ - ../.. - -build_pug: - docker build \ - --build-arg SENTRY_RELEASE \ - --tag $(IMAGE_CI)-pug \ - --target pug \ - --file Dockerfile \ - ../.. - -build: - docker build \ - --build-arg SENTRY_RELEASE \ - --tag $(IMAGE_REPO_FINAL) \ - --tag $(IMAGE_SCRATCH) \ - --target app \ - --file Dockerfile \ - ../.. - -publish: - docker push $(IMAGE_REPO_FINAL) - -push_branch: - docker push $(IMAGE_CACHE) - -push_scratch: - docker push $(IMAGE_SCRATCH) - -SENTRY_IMAGE=getsentry/sentry-cli:2.16.1 -sentry_prefetch: - docker pull $(SENTRY_IMAGE) - -sentry_upload: - docker run --rm \ - --volume $(MONOREPO):$(MONOREPO) --workdir $(PWD) \ - --user "$(shell id -u):$(shell id -g)" \ - --env BRANCH_NAME --env SENTRY_RELEASE \ - --entrypoint bin/sentry_upload \ - $(SENTRY_IMAGE) - -tar: - COMPOSE_PROJECT_NAME=tar_$(BUILD_DIR_NAME) $(DOCKER_COMPOSE) run --rm tar - COMPOSE_PROJECT_NAME=tar_$(BUILD_DIR_NAME) $(DOCKER_COMPOSE) down -v -t 0 - -build_storybook: - yarn run lezer-latex:generate - mkdir -p data/storybook/ - rm -rf data/storybook/$(BRANCH_NAME) - yarn run build-storybook --output-dir data/storybook/$(BRANCH_NAME) --loglevel warn --quiet - -storybook_index: - mkdir -p data/storybook/ - m4 \ - -DLIST="$(shell gsutil ls "${BUCKET}/" \ - | sed -E "s@^${BUCKET}/([^/]+)/@\1@" \ - | grep -v -e index.html \ - | sort \ - | tr '\n' ,)" \ - -DBRANCH_NAME="${BRANCH_NAME}" .storybook/index.html.m4 \ - > data/storybook/index.html - -MODULE_TARGETS = \ - $(TEST_ACCEPTANCE_MODULES_SAAS) \ - $(TEST_ACCEPTANCE_MODULES_SERVER_CE) \ - $(TEST_ACCEPTANCE_MODULES_SERVER_PRO) \ - $(TEST_ACCEPTANCE_MODULES_MERGED_INNER) \ - $(CLEAN_TEST_ACCEPTANCE_MODULES) \ - $(TEST_UNIT_MODULES) \ - -$(MODULE_TARGETS): - $(MAKE) -C $(dir $@) $(notdir $@) BUILD_DIR_NAME=$(BUILD_DIR_NAME) - -.PHONY: - $(MODULE_TARGETS) \ - compile_modules compile_modules_full clean_ci \ - test test_module mongo_migrations_for_tests test_unit test_unit_app \ - test_unit_modules test_unit_module test_frontend_jsdom \ - test_acceptance test_acceptance_app test_acceptance_modules \ - test_acceptance_module ci format format_fix lint \ - shellcheck shellcheck_fix \ - build publish tar diff --git a/services/web/Makefile.module b/services/web/Makefile.module deleted file mode 100644 index 3a40b73d80..0000000000 --- a/services/web/Makefile.module +++ /dev/null @@ -1,74 +0,0 @@ -BUILD_DIR_NAME ?= web -ifeq ($(BRANCH_NAME),) - export BRANCH_NAME := $(shell git rev-parse --abbrev-ref HEAD) -endif -export BRANCH_NAME_TAG_SAFE := $(shell echo "$(BRANCH_NAME)" | sed 's/\//\-\-/g' | cut -c1-96) -export MODULE_NAME := $(notdir $(shell pwd)) -export MODULE_DIR := modules/$(MODULE_NAME) -export PROJECT_NAME = web -export RETRIES ?= $(if $(CI),3,0) -export MOCHA_GREP ?= - -export OVERLEAF_CONFIG = /overleaf/services/web/$(MODULE_DIR)/test/acceptance/config/settings.test.js -export BASE_CONFIG ?= /overleaf/services/web/test/acceptance/config/settings.test.saas.js - -CFG_SAAS=/overleaf/services/web/test/acceptance/config/settings.test.saas.js -CFG_SERVER_CE=/overleaf/services/web/test/acceptance/config/settings.test.server-ce.js -CFG_SERVER_PRO=/overleaf/services/web/test/acceptance/config/settings.test.server-pro.js - -DOCKER_COMPOSE_FLAGS ?= -f docker-compose.yml -DOCKER_COMPOSE := cd ../../ && docker compose ${DOCKER_COMPOSE_FLAGS} - -ifeq (,$(wildcard test/unit)) -test_unit: - -else -test_unit: export COMPOSE_PROJECT_NAME=unit_test_$(BUILD_DIR_NAME)_$(MODULE_NAME) -test_unit: - ${DOCKER_COMPOSE} run --rm test_unit yarn test:unit:run_dir ${MOCHA_ARGS} $(MODULE_DIR)/test/unit/src - ${DOCKER_COMPOSE} down - -endif - -ALL_TEST_ACCEPTANCE_VARIANTS := \ - test_acceptance \ - test_acceptance_saas \ - test_acceptance_server_ce \ - test_acceptance_server_pro \ - -ifeq (,$(wildcard test/acceptance)) -$(ALL_TEST_ACCEPTANCE_VARIANTS) test_acceptance_merged_inner: - @echo - @echo Module $(MODULE_NAME) does not have acceptance tests. - @echo - -clean_test_acceptance: - -else -ifeq ($(DEBUG),true) -TEST_ACCEPTANCE_DEBUG_PORT := -p 127.0.0.1:19999:19999 -TEST_ACCEPTANCE_NPM_SCRIPT := test:acceptance:debug:run_dir -else -TEST_ACCEPTANCE_DEBUG_PORT := -TEST_ACCEPTANCE_NPM_SCRIPT := test:acceptance:run_dir -endif - -test_acceptance_saas: export BASE_CONFIG = $(CFG_SAAS) -test_acceptance_server_ce: export BASE_CONFIG = $(CFG_SERVER_CE) -test_acceptance_server_pro: export BASE_CONFIG = $(CFG_SERVER_PRO) - -$(ALL_TEST_ACCEPTANCE_VARIANTS): export COMPOSE_PROJECT_NAME=acceptance_test_$(BUILD_DIR_NAME)_$(MODULE_NAME) -$(ALL_TEST_ACCEPTANCE_VARIANTS): - $(MAKE) --no-print-directory clean_test_acceptance - ${DOCKER_COMPOSE} run --rm $(TEST_ACCEPTANCE_DEBUG_PORT) test_acceptance yarn $(TEST_ACCEPTANCE_NPM_SCRIPT) -- ${MOCHA_ARGS} $(MODULE_DIR)/test/acceptance/src - $(MAKE) --no-print-directory clean_test_acceptance - -test_acceptance_merged_inner: - cd ../../ && \ - yarn test:acceptance:run_dir -- ${MOCHA_ARGS} $(MODULE_DIR)/test/acceptance/src - -clean_test_acceptance: export COMPOSE_PROJECT_NAME=acceptance_test_$(BUILD_DIR_NAME)_$(MODULE_NAME) -clean_test_acceptance: - ${DOCKER_COMPOSE} down -v -t 0 - -endif diff --git a/services/web/bin/cdn_upload b/services/web/bin/cdn_upload deleted file mode 100755 index 52281e4dd0..0000000000 --- a/services/web/bin/cdn_upload +++ /dev/null @@ -1,73 +0,0 @@ -#!/bin/bash -set -eEu - -function upload_into_bucket() { - bucket=$1 - - # stylesheets - bin/cdn_upload_batch 'text/css' "$bucket" '.css' \ - -x '.+(?&2 'WARN: %d local file(s) not available in bucket %s:\n' \ - ${#missing_from_bucket[@]} "$bucket" - printf >&2 ' - %s\n' "${missing_from_bucket[@]}" - return 1 - fi -} - -# Upload to staging CDN if branch is either 'main' or 'staging-main' -if [[ "$BRANCH_NAME" == "main" ]] || [[ "$BRANCH_NAME" == "staging-main" ]]; then - tar --directory=/tmp/ -xf build.tar - - # delete source maps - find /tmp/public -name '*.js.map' -delete - - bin/compress_assets - - upload_into_bucket "$CDN_STAG" && - verify_upload_into_bucket "$CDN_STAG" || exit 3 & - pid_staging=$! # record pid of the detached process "upload && verify || exit 3") - - pid_production= - # Only upload to production CDN if branch is - if [[ "$BRANCH_NAME" == "main" ]]; then - upload_into_bucket "$CDN_PROD" && - verify_upload_into_bucket "$CDN_PROD" || exit 4 & - pid_production=$! # record pid of the detached process "upload && verify || exit 4") - fi - - wait "$pid_staging" # wait for staging upload to finish, wait(1) will exit if the upload failed - if [[ -n "$pid_production" ]]; then - wait "$pid_production" # wait for production upload to finish (if started), wait(1) will exit if the upload failed - fi -fi diff --git a/services/web/bin/cdn_upload_batch b/services/web/bin/cdn_upload_batch deleted file mode 100755 index 606e2a92cc..0000000000 --- a/services/web/bin/cdn_upload_batch +++ /dev/null @@ -1,47 +0,0 @@ -#!/bin/bash -set -e - -content_type=$1 -bucket=$2 -text_extension=$3 -shift 3 -content_type_options="" -if [[ "$content_type" != "-" ]]; then - content_type_options="-h Content-Type:${content_type};charset=utf-8" -fi - -# DOCS for gsutil -- it does not have long command line flags! -## global flags -# -h NAME:VALUE add header, can occur multiples times -# -m upload with multiple threads -## rsync flags -# -c use checksums for determining changed files (mtime is not stable) -# -r traverse into directories recursively -# -x Python regex for excluding files from the sync -if [[ "$text_extension" == "-" || $(find /tmp/public -type f -name "*$text_extension" | wc -l) != "0" ]]; then - # Potentially skip upload of non-compressed .js/.css files. - # shellcheck disable=SC2086 - gsutil \ - -h "Cache-Control:public, max-age=31536000" \ - ${content_type_options} \ - -m \ - rsync \ - -c \ - -r \ - "$@" \ - "/tmp/public/" \ - "${bucket}/public/" -fi - -# shellcheck disable=SC2086 -gsutil \ - -h "Cache-Control:public, max-age=31536000" \ - -h "Content-Encoding:gzip" \ - ${content_type_options} \ - -m \ - rsync \ - -c \ - -r \ - "$@" \ - "/tmp/compressed/public/" \ - "${bucket}/public/" diff --git a/services/web/bin/check_extracted_translations b/services/web/bin/check_extracted_translations deleted file mode 100755 index 569af5ccf0..0000000000 --- a/services/web/bin/check_extracted_translations +++ /dev/null @@ -1,24 +0,0 @@ -#!/bin/bash - -set -e - -# Ensure all locales used in the frontend are tracked -OUTPUT=data/dumpFolder/i18next-scanner -trap 'rm -rf "$OUTPUT"' EXIT -npx i18next-scanner --output "$OUTPUT" -ACTUAL=frontend/extracted-translations.json -EXPECTED="$OUTPUT/frontend/extracted-translations.json" -if ! diff "$ACTUAL" "$EXPECTED"; then - cat <&2 - -services/web/frontend/extracted-translations.json is not up-to-date. - ---- -Try running: - - internal$ bin/run web npm run extract-translations - ---- -MSG - exit 1 -fi diff --git a/services/web/bin/compress_assets b/services/web/bin/compress_assets deleted file mode 100755 index e50f0a3389..0000000000 --- a/services/web/bin/compress_assets +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash -set -e -o pipefail - -SCRIPT_PATH=$(realpath "${BASH_SOURCE[0]}") -SCRIPT_DIR=$(dirname "$SCRIPT_PATH") - -pushd /tmp - -find public/ -type d | sed 's!^!compressed/!' | xargs mkdir --parents - -find public/ -type f -print0 \ -| xargs --null --max-args 20 --max-procs 10 "$SCRIPT_DIR/compress_batch_of_assets" - -popd diff --git a/services/web/bin/compress_batch_of_assets b/services/web/bin/compress_batch_of_assets deleted file mode 100755 index 4a650da4b8..0000000000 --- a/services/web/bin/compress_batch_of_assets +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash -set -e - -for file in "$@"; do - file_gzipped="compressed/$file" - - gzip -9 --no-name --stdout "$file" > "$file_gzipped" - - before=$(stat -c%s "$file") - after=$(stat -c%s "$file_gzipped") - if [[ "$after" -ge "$before" ]]; then - rm "$file_gzipped" - else - rm "$file" - fi -done diff --git a/services/web/bin/copy_external_pages b/services/web/bin/copy_external_pages deleted file mode 100755 index db94156481..0000000000 --- a/services/web/bin/copy_external_pages +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash -set -e - -# Branding -cp app/views/external/robots.txt public/robots.txt -cp app/views/external/googlebdb0f8f7f4a17241.html public/googlebdb0f8f7f4a17241.html diff --git a/services/web/bin/lint_flag_res_send_usage b/services/web/bin/lint_flag_res_send_usage deleted file mode 100755 index a4ff1f5f62..0000000000 --- a/services/web/bin/lint_flag_res_send_usage +++ /dev/null @@ -1,48 +0,0 @@ -#!/bin/bash - -set -e - -POTENTIAL_SEND_USAGE=$(\ - grep \ - --files-with-matches \ - --recursive \ - app.mjs \ - app/ \ - modules/*/app \ - test/acceptance/ \ - modules/*/test/acceptance/ \ - --regex "\.send\b" \ - --regex "\bsend(" \ -) -HELPER_MODULE="app/src/infrastructure/Response.mjs" -MOCK_MODULE="test/acceptance/src/mocks/MockResponse.mjs" -if [[ "$POTENTIAL_SEND_USAGE" == "$HELPER_MODULE" ]] || [[ "$file" == "$MOCK_MODULE" ]]; then - exit 0 -fi - -for file in ${POTENTIAL_SEND_USAGE}; do - if [[ "$file" == "$HELPER_MODULE" ]] || [[ "$file" == "$MOCK_MODULE" ]]; then - continue - fi - - cat <&2 - -ERROR: $file contains a potential use of 'res.send'. - ---- -$(grep -n -C 3 "$file" --regex "\.send\b" --regex "\bsend(") ---- - -Using 'res.send' is prone to introducing XSS vulnerabilities. - -Consider using 'res.json' or one of the helpers in $HELPER_MODULE. - -If this is a false-positive, consider using a more specific name than 'send' - for your newly introduced function. - -Links: - - https://github.com/overleaf/internal/issues/6268 - -MSG - exit 1 -done diff --git a/services/web/bin/lint_locales b/services/web/bin/lint_locales deleted file mode 100755 index dc931b7cd2..0000000000 --- a/services/web/bin/lint_locales +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/bash - -set -e - -# Ensure all locales are still in use -node scripts/translations/cleanupUnusedLocales.js --check - -# Ensure all locales use the same variables -node scripts/translations/checkVariables.js --ignore-orphaned-translations diff --git a/services/web/bin/lint_pug_templates b/services/web/bin/lint_pug_templates deleted file mode 100755 index 6f255d9745..0000000000 --- a/services/web/bin/lint_pug_templates +++ /dev/null @@ -1,31 +0,0 @@ -#!/bin/sh - -set -e - -TEMPLATES_EXTENDING_META_BLOCK=$(\ - grep \ - --files-with-matches \ - --recursive app/views modules/*/app/views \ - --regex 'block append meta' \ - --regex 'block prepend meta' \ - --regex 'append meta' \ - --regex 'prepend meta' \ -) - -for file in ${TEMPLATES_EXTENDING_META_BLOCK}; do - if ! grep "$file" --quiet --extended-regexp -e 'extends .+layout'; then - cat <&2 - -ERROR: $file is a partial template and extends 'block meta'. - -Using block append/prepend in a partial will duplicate the block contents into - the due to a bug in pug. -Putting meta tags in the can lead to Angular XSS. - -You will need to refactor the partial and move the block into the top level - page template that extends the global layout.pug. - -MSG - exit 1 - fi -done diff --git a/services/web/bin/push-translations-changes.sh b/services/web/bin/push-translations-changes.sh deleted file mode 100755 index ba24757052..0000000000 --- a/services/web/bin/push-translations-changes.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/bash -set -e - -SCRIPT_PATH=$(realpath "${BASH_SOURCE[0]}") -SCRIPT_DIR=$(dirname "$SCRIPT_PATH") -WEB_DIR=$(dirname "$SCRIPT_DIR") - -cd "$WEB_DIR" - -if [[ $(git status --porcelain=2 locales/) ]]; then - git add locales/* - git commit -m "auto update translation" - # Switch the cloudbuild clone from https to ssh authentication. - git remote set-url --push origin git@github.com:overleaf/internal.git - git push origin "HEAD:$BRANCH_NAME" -else - echo 'No changes' -fi diff --git a/services/web/bin/routes b/services/web/bin/routes deleted file mode 100755 index 857d182256..0000000000 --- a/services/web/bin/routes +++ /dev/null @@ -1,130 +0,0 @@ -#! /usr/bin/env node - -const acorn = require('acorn') -const acornWalk = require('acorn-walk') -const fs = require('fs') -const _ = require('lodash') -const { glob } = require('glob') -const escodegen = require('escodegen') -const print = console.log - -const Methods = new Set([ - 'get', - 'head', - 'post', - 'put', - 'delete', - 'connect', - 'options', - 'trace', - 'patch', -]) - -const isMethod = str => { - return Methods.has(str) -} - -// Check if the expression is a call on a router, return data about it, or null -const routerCall = callExpression => { - const callee = callExpression.callee - const property = callee.property - const args = callExpression.arguments - if (!callee.object || !callee.object.name) { - return false - } - const routerName = callee.object.name - if ( - // Match known names for the Express routers: app, webRouter, whateverRouter, etc... - isMethod(property.name) && - (routerName === 'app' || routerName.match('^.*[rR]outer$')) - ) { - return { - routerName, - method: property.name, - args, - } - } else { - return null - } -} - -const formatMethodCall = expression => { - return escodegen.generate(expression, { format: { compact: true } }) -} - -const parseAndPrintRoutesSync = path => { - const content = fs.readFileSync(path) - // Walk the AST (Abstract Syntax Tree) - acornWalk.simple( - acorn.parse(content, { sourceType: 'module', ecmaVersion: 2020 }), - { - // We only care about call expression ( like `a.b()` ) - CallExpression(node) { - const call = routerCall(node) - if (call) { - const firstArg = _.first(call.args) - try { - print( - ` ${formatRouterName(call.routerName)}\t .${call.method} \t: ${ - firstArg.value - } => ${call.args.slice(1).map(formatMethodCall).join(' => ')}` - ) - } catch (e) { - print('>> Error') - print(e) - print(JSON.stringify(call)) - process.exit(1) - } - } - }, - } - ) -} - -const routerNameMapping = { - privateApiRouter: 'privateApi', - publicApiRouter: 'publicApi', -} -const formatRouterName = name => { - return routerNameMapping[name] || name -} - -const main = async () => { - // Take an optional filter to apply to file names - const filter = process.argv[2] || null - - if (filter && (filter === '--help' || filter === 'help')) { - print('') - print(' Usage: bin/routes [filter]') - print(' Examples:') - print(' bin/routes') - print(' bin/routes GitBridge') - print('') - process.exit(0) - } - - // Find all routers - const files = await glob('*[rR]outer.*js', { matchBase: true }) - for (const file of files) { - if (file.match('^node_modules.*$') || file.match('.*/public/.*')) { - continue - } - // Restrict to the filter (if filter is present) - if (filter && !file.match(`.*${filter}.*`)) { - continue - } - print(`[${file}]`) - try { - parseAndPrintRoutesSync(file) - } catch (_e) { - print('>> Error parsing file') - } - } -} - -if (require.main === module) { - main().then(() => process.exit(0)).catch(() => { - console.error(err) - process.exit(1) - }) -} diff --git a/services/web/bin/run b/services/web/bin/run deleted file mode 100755 index f0af921c79..0000000000 --- a/services/web/bin/run +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash - -pushd .. -bin/run "$*" -RV=$? -popd || exit 1 - -exit $RV diff --git a/services/web/bin/sentry_upload b/services/web/bin/sentry_upload deleted file mode 100755 index 45a2a9336a..0000000000 --- a/services/web/bin/sentry_upload +++ /dev/null @@ -1,22 +0,0 @@ -#!/bin/sh -set -e - -echo "BRANCH_NAME=$BRANCH_NAME" -echo "SENTRY_RELEASE=$SENTRY_RELEASE" - -if [[ "$BRANCH_NAME" == "main" ]]; then - rm -rf sentry_upload - mkdir sentry_upload - tar --directory sentry_upload -xf build.tar - cd sentry_upload/public - - sentry-cli releases new "$SENTRY_RELEASE" - sentry-cli releases set-commits --auto "$SENTRY_RELEASE" - sentry-cli sourcemaps upload --release="$SENTRY_RELEASE" . - sentry-cli releases finalize "$SENTRY_RELEASE" - - cd ../.. - rm -rf sentry_upload -else - echo "Not on main branch. Skipping sentry upload." -fi diff --git a/services/web/bin/test_acceptance_run_dir b/services/web/bin/test_acceptance_run_dir deleted file mode 100755 index 151af59120..0000000000 --- a/services/web/bin/test_acceptance_run_dir +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/bash - -declare -a mocha_prefix=() - -if [[ "$COVERAGE_ACCEPTANCE_TESTS" == "true" ]]; then - name=$(echo "$BASE_CONFIG $OVERLEAF_CONFIG" "$@" | sha256sum) - dir=data/coverage/mocha-acceptance-${name} - echo "Enabling coverage. Writing into ${dir}" - mocha_prefix+=("c8" "--reporter=clover" "--all" "--include={app.mjs,app/**/*.{mjs,js},modules/*/index.mjs,modules/*/app/**/*.{mjs,js}}" "--exclude=app/src/Features/Metadata/packageMapping.mjs" "--report-dir=${dir}") -fi - -"${mocha_prefix[@]}" mocha --recursive --timeout 25000 --grep="$MOCHA_GREP" --require test/acceptance/bootstrap.js --retries="$RETRIES" "$@" diff --git a/services/web/bin/test_unit_run_dir b/services/web/bin/test_unit_run_dir deleted file mode 100755 index 02e5a03f5b..0000000000 --- a/services/web/bin/test_unit_run_dir +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/bash - -declare -a vitest_args=("$@") - -if [[ -n "$MOCHA_GREP" ]]; then - vitest_args+=("--testNamePattern" "$MOCHA_GREP") -fi - -if [[ -n "$VITEST_NO_CACHE" ]]; then - echo "Disabling cache for vitest." - vitest_args+=("--no-cache") -fi - -echo "Running unit tests in directory: $*" - -exec yarn run vitest run "${vitest_args[@]}" diff --git a/services/web/buildscript.txt b/services/web/buildscript.txt deleted file mode 100644 index 375e696a54..0000000000 --- a/services/web/buildscript.txt +++ /dev/null @@ -1,8 +0,0 @@ -web ---dependencies=mongo ---docker-repos=us-east1-docker.pkg.dev/overleaf-ops/ol-docker ---env-add= ---env-pass-through= ---esmock-loader=False ---node-version=24.14.1 ---public-repo=False diff --git a/services/web/certs/DigiCertGlobalG2TLSRSASHA2562020CA1-1.crt.pem b/services/web/certs/DigiCertGlobalG2TLSRSASHA2562020CA1-1.crt.pem deleted file mode 100644 index f06ba91adf..0000000000 --- a/services/web/certs/DigiCertGlobalG2TLSRSASHA2562020CA1-1.crt.pem +++ /dev/null @@ -1,28 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIEyDCCA7CgAwIBAgIQDPW9BitWAvR6uFAsI8zwZjANBgkqhkiG9w0BAQsFADBh -MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 -d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBH -MjAeFw0yMTAzMzAwMDAwMDBaFw0zMTAzMjkyMzU5NTlaMFkxCzAJBgNVBAYTAlVT -MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxMzAxBgNVBAMTKkRpZ2lDZXJ0IEdsb2Jh -bCBHMiBUTFMgUlNBIFNIQTI1NiAyMDIwIENBMTCCASIwDQYJKoZIhvcNAQEBBQAD -ggEPADCCAQoCggEBAMz3EGJPprtjb+2QUlbFbSd7ehJWivH0+dbn4Y+9lavyYEEV -cNsSAPonCrVXOFt9slGTcZUOakGUWzUb+nv6u8W+JDD+Vu/E832X4xT1FE3LpxDy -FuqrIvAxIhFhaZAmunjZlx/jfWardUSVc8is/+9dCopZQ+GssjoP80j812s3wWPc -3kbW20X+fSP9kOhRBx5Ro1/tSUZUfyyIxfQTnJcVPAPooTncaQwywa8WV0yUR0J8 -osicfebUTVSvQpmowQTCd5zWSOTOEeAqgJnwQ3DPP3Zr0UxJqyRewg2C/Uaoq2yT -zGJSQnWS+Jr6Xl6ysGHlHx+5fwmY6D36g39HaaECAwEAAaOCAYIwggF+MBIGA1Ud -EwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFHSFgMBmx9833s+9KTeqAx2+7c0XMB8G -A1UdIwQYMBaAFE4iVCAYlebjbuYP+vq5Eu0GF485MA4GA1UdDwEB/wQEAwIBhjAd -BgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwdgYIKwYBBQUHAQEEajBoMCQG -CCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20wQAYIKwYBBQUHMAKG -NGh0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEdsb2JhbFJvb3RH -Mi5jcnQwQgYDVR0fBDswOTA3oDWgM4YxaHR0cDovL2NybDMuZGlnaWNlcnQuY29t -L0RpZ2lDZXJ0R2xvYmFsUm9vdEcyLmNybDA9BgNVHSAENjA0MAsGCWCGSAGG/WwC -ATAHBgVngQwBATAIBgZngQwBAgEwCAYGZ4EMAQICMAgGBmeBDAECAzANBgkqhkiG -9w0BAQsFAAOCAQEAkPFwyyiXaZd8dP3A+iZ7U6utzWX9upwGnIrXWkOH7U1MVl+t -wcW1BSAuWdH/SvWgKtiwla3JLko716f2b4gp/DA/JIS7w7d7kwcsr4drdjPtAFVS -slme5LnQ89/nD/7d+MS5EHKBCQRfz5eeLjJ1js+aWNJXMX43AYGyZm0pGrFmCW3R -bpD0ufovARTFXFZkAdl9h6g4U5+LXUZtXMYnhIHUfoyMo5tS58aI7Dd8KvvwVVo4 -chDYABPPTHPbqjc1qCmBaZx2vN4Ye5DUys/vZwP9BFohFrH/6j/f3IL16/RZkiMN -JCqVJUzKoZHm1Lesh3Sz8W2jmdv51b2EQJ8HmA== ------END CERTIFICATE----- diff --git a/services/web/data/.gitignore b/services/web/data/.gitignore deleted file mode 100644 index 8790881c38..0000000000 --- a/services/web/data/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -# ignore everything in this directory -* -# Except this file -!.gitignore diff --git a/services/web/data/coverage/.gitignore b/services/web/data/coverage/.gitignore deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/services/web/data/dumpFolder/.gitignore b/services/web/data/dumpFolder/.gitignore deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/services/web/data/logs/.gitignore b/services/web/data/logs/.gitignore deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/services/web/data/pdfs/.gitignore b/services/web/data/pdfs/.gitignore deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/services/web/data/projectHistories/.gitignore b/services/web/data/projectHistories/.gitignore deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/services/web/data/reports/.gitignore b/services/web/data/reports/.gitignore deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/services/web/data/uploads/.gitignore b/services/web/data/uploads/.gitignore deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/services/web/data/zippedProjects/.gitignore b/services/web/data/zippedProjects/.gitignore deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/services/web/docker-compose.ci.yml b/services/web/docker-compose.ci.yml deleted file mode 100644 index 3353b33e8a..0000000000 --- a/services/web/docker-compose.ci.yml +++ /dev/null @@ -1,180 +0,0 @@ -volumes: - data: - -services: - test_unit: - build: - context: ../.. - dockerfile: services/web/Dockerfile - image: ci/$PROJECT_NAME:$BRANCH_NAME_TAG_SAFE-$BUILD_NUMBER - logging: - driver: local - user: node - volumes: - - ../../bin/shared/wait_for_it:/overleaf/bin/shared/wait_for_it - - ./data/coverage:/overleaf/services/web/data/coverage - - ./data/reports:/overleaf/services/web/data/reports - entrypoint: /overleaf/bin/shared/wait_for_it mongo:27017 --timeout=60 -- - command: yarn run test:unit:app - working_dir: /overleaf/services/web - env_file: docker-compose.common.env - environment: - CI: - MODULE_NAME: - JUNIT_ROOT_SUITE_NAME: - BASE_CONFIG: - OVERLEAF_CONFIG: - NODE_ENV: test - NODE_OPTIONS: "--unhandled-rejections=strict" - REDIS_HOST: redis_test - VITEST_NO_CACHE: true - COVERAGE_UNIT_TESTS: - NO_COLOR: true - MONOREPO: - depends_on: - - redis_test - - mongo - - test_acceptance: - build: - context: ../.. - dockerfile: services/web/Dockerfile - image: ci/$PROJECT_NAME:$BRANCH_NAME_TAG_SAFE-$BUILD_NUMBER - logging: - driver: local - working_dir: /overleaf/services/web - env_file: docker-compose.common.env - environment: - CI: - MODULE_NAME: - JUNIT_ROOT_SUITE_NAME: - BASE_CONFIG: - OVERLEAF_CONFIG: - REDIS_HOST: redis_test - COVERAGE_ACCEPTANCE_TESTS: - NO_COLOR: true - MONOREPO: - RETRIES: - extra_hosts: - - "www.overleaf.test:127.0.0.1" - volumes: - - ../../bin/shared/wait_for_it:/overleaf/bin/shared/wait_for_it - - ./data/coverage:/overleaf/services/web/data/coverage - - ./data/reports:/overleaf/services/web/data/reports - entrypoint: /overleaf/bin/shared/wait_for_it mongo:27017 --timeout=60 -- - command: yarn run test:acceptance:app - depends_on: - - redis_test - - mongo - - saml - - ldap - tmpfs: - - /overleaf/services/web/data:uid=1000,gid=1000 - - test_frontend_jsdom: - build: - context: ../.. - dockerfile: services/web/Dockerfile - image: ci/$PROJECT_NAME:$BRANCH_NAME_TAG_SAFE-$BUILD_NUMBER - volumes: - - ./data/reports:/overleaf/services/web/data/reports - logging: - driver: local - user: node - working_dir: /overleaf/services/web - command: yarn run test:frontend - environment: - NODE_OPTIONS: "--unhandled-rejections=strict" - CI: - JUNIT_ROOT_SUITE_NAME: - NO_COLOR: true - - test_frontend_ct: - build: - context: ../../dockerfiles/cypress - args: - - USER_UID - - USER_GID - logging: - driver: local - working_dir: /overleaf/services/web - environment: - CI: - CYPRESS_DOWNLOADS: - CYPRESS_RESULTS: - CYPRESS_SPEC_PATTERN: - CYPRESS_EXCLUDE_SPEC_PATTERN: - NO_COLOR: true - volumes: - - /dev/shm/overleaf:/overleaf - - ./data/reports:/overleaf/services/web/data/reports - tmpfs: - - /overleaf/services/web/node_modules/.cache:uid=1000,gid=1000 - entrypoint: yarn - command: - - "run" - - "cypress:run-ct" - - test_writefull: - build: - context: ../.. - dockerfile: services/web/Dockerfile - image: ci/$PROJECT_NAME:$BRANCH_NAME_TAG_SAFE-$BUILD_NUMBER - logging: - driver: local - user: node - working_dir: /overleaf/services/web - command: yarn run test:writefull - volumes: - - ./data/reports:/overleaf/services/web/data/reports - environment: - CI: - JUNIT_ROOT_SUITE_NAME: - NODE_OPTIONS: "--unhandled-rejections=strict" - NO_COLOR: true - - tar: - image: ci/$PROJECT_NAME:$BRANCH_NAME_TAG_SAFE-$BUILD_NUMBER-webpack - volumes: - - ./:/tmp/build/ - command: tar -cf /tmp/build/build.tar public/ - user: root - - redis_test: - image: redis:7.4.8 - - mongo: - image: mongo:8.0.11 - command: --replSet overleaf --notablescan - volumes: - - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js - - ../../bin/shared/mongodb-docker-entrypoint-wait.sh:/mongodb-docker-entrypoint-wait.sh - entrypoint: /mongodb-docker-entrypoint-wait.sh - environment: - MONGO_INITDB_DATABASE: sharelatex - extra_hosts: - # Required when using the automatic database setup for initializing the - # replica set. This override is not needed when running the setup after - # starting up mongo. - - mongo:127.0.0.1 - - ldap: - image: rroemhild/test-openldap:1.1 - logging: - driver: none - # Workaround large memory allocation (using the max-open-files-limit as socket buffer scale). - # REF: https://github.com/moby/moby/issues/8231#issuecomment-63877553 - # REF: https://github.com/moby/moby/issues/8231#issuecomment-63871343 - command: - - "bash" - - "-c" - - "ulimit -n 1024 && exec bash /run.sh" - - saml: - image: us-east1-docker.pkg.dev/overleaf-ops/ol-docker/saml-test - logging: - driver: none - environment: - SAML_BASE_URL_PATH: "http://saml/simplesaml/" - SAML_TEST_SP_ENTITY_ID: "overleaf-test-saml" - SAML_TEST_SP_LOCATION: "http://www.overleaf.test:23000/saml/callback" diff --git a/services/web/docker-compose.common.env b/services/web/docker-compose.common.env deleted file mode 100644 index bb7d3d8438..0000000000 --- a/services/web/docker-compose.common.env +++ /dev/null @@ -1,55 +0,0 @@ -ADMIN_PRIVILEGE_AVAILABLE=true -BCRYPT_ROUNDS=1 -WEB_PORT=23000 -REDIS_HOST=redis -QUEUES_REDIS_HOST=redis -ANALYTICS_QUEUES_REDIS_HOST=redis -MONGO_URL=mongodb://mongo/test-overleaf -OVERLEAF_ALLOW_PUBLIC_ACCESS=true -LINKED_URL_PROXY=http://127.0.0.1:6543 -ENABLED_LINKED_FILE_TYPES=url,project_file,project_output_file,mendeley,zotero -NODE_ENV=test -NODE_OPTIONS=--unhandled-rejections=strict -LOCK_MANAGER_MAX_LOCK_WAIT_TIME=30000 -COOKIE_DOMAIN=.overleaf.test -ADMIN_URL=http://admin.overleaf.test -PUBLIC_URL=http://www.overleaf.test:23000 -HTTP_TEST_HOST=www.overleaf.test -OT_JWT_AUTH_KEY="very secret key" -EXTERNAL_AUTH=none -RECAPTCHA_ENDPOINT=http://127.0.0.1:2222/recaptcha/api/siteverify -# Server-Pro LDAP -OVERLEAF_LDAP_URL=ldap://ldap:389 -OVERLEAF_LDAP_SEARCH_BASE=ou=people,dc=planetexpress,dc=com -OVERLEAF_LDAP_SEARCH_FILTER="(uid={{username}})" -OVERLEAF_LDAP_BIND_DN=cn=admin,dc=planetexpress,dc=com -OVERLEAF_LDAP_BIND_CREDENTIALS=GoodNewsEveryone -OVERLEAF_LDAP_EMAIL_ATT=mail -OVERLEAF_LDAP_NAME_ATT=cn -OVERLEAF_LDAP_LAST_NAME_ATT=sn -OVERLEAF_LDAP_UPDATE_USER_DETAILS_ON_LOGIN=true -OVERLEAF_LDAP_IS_ADMIN_ATT=ou -OVERLEAF_LDAP_IS_ADMIN_ATT_VALUE=Staff -# Server-Pro SAML -OVERLEAF_SAML_ENTRYPOINT=http://saml/simplesaml/saml2/idp/SSOService.php -OVERLEAF_SAML_CALLBACK_URL=http://saml/saml/callback -OVERLEAF_SAML_ISSUER=overleaf-test-saml -OVERLEAF_SAML_IDENTITY_SERVICE_NAME="SAML Test Server" -OVERLEAF_SAML_EMAIL_FIELD=email -OVERLEAF_SAML_FIRST_NAME_FIELD=givenName -OVERLEAF_SAML_LAST_NAME_FIELD=sn -OVERLEAF_SAML_UPDATE_USER_DETAILS_ON_LOGIN=true -OVERLEAF_SAML_IS_ADMIN_FIELD=sn -OVERLEAF_SAML_IS_ADMIN_FIELD_VALUE=Brookes -# simplesaml cert from https://github.com/overleaf/google-ops/tree/master/docker-images/saml-test/var-simplesamlphp/cert -OVERLEAF_SAML_CERT=MIIDXTCCAkWgAwIBAgIJAOvOeQ4xFTzsMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNVBAYTAkdCMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMTYxMTE1MTQxMjU5WhcNMjYxMTE1MTQxMjU5WjBFMQswCQYDVQQGEwJHQjETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxCT6MBe5G9VoLU8MfztOEbUhnwLp17ak8eFUqxqeXkkqtWB0b/cmIBU3xoQoO3dIF8PBzfqehqfYVhrNt/TFgcmDfmJnPJRL1RJWMW3VmiP5odJ3LwlkKbZpkeT3wZ8HEJIR1+zbpxiBNkbd2GbdR1iumcsHzMYX1A2CBj+ZMV5VijC+K4P0e9c05VsDEUtLmfeAasJAiumQoVVgAe/BpiXjICGGewa6EPFI7mKkifIRKOGxdRESwZZjxP30bI31oDN0cgKqIgSJtJ9nfCn9jgBMBkQHu42WMuaWD4jrGd7+vYdX+oIfArs9aKgAH5kUGhGdew2R9SpBefrhbNxG8QIDAQABo1AwTjAdBgNVHQ4EFgQU+aSojSyyLChP/IpZcafvSdhj7KkwHwYDVR0jBBgwFoAU+aSojSyyLChP/IpZcafvSdhj7KkwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEABl3+OOVLBWMKs6PjA8lPuloWDNzSr3v76oUcHqAb+cfbucjXrOVsS9RJ0X9yxvCQyfM9FfY43DbspnN3izYhdvbJD8kKLNf0LA5st+ZxLfy0ACyL2iyAwICaqndqxAjQYplFAHmpUiu1DiHckyBPekokDJd+ze95urHMOsaGS5RWPoKJVE0bkaAeZCmEu0NNpXRSBiuxXSTeSAJfv6kyE/rkdhzUKyUl/cGQFrsVYfAFQVA+W6CKOh74ErSEzSHQQYndl7nD33snD/YqdU1ROxV6aJzLKCg+sdj+wRXSP2u/UHnM4jW9TGJfhO42jzL6WVuEvr9q4l7zWzUQKKKhtQ== -# DEVICE_HISTORY_SECRET has been generated using: -# NOTE: crypto.generateKeySync was added in v15, v16 is the next LTS release. -# $ docker run --rm node:24.14.1 --print 'require("crypto").generateKeySync("aes", { length: 256 }).export().toString("hex")' -DEVICE_HISTORY_SECRET=1b46e6cdf72db02845da06c9517c9cfbbfa0d87357479f4e1df3ce160bd54807 -QUEUE_PROCESSING_ENABLED=true -CURRENT_IMAGE_NAME=texlive-full:2025.1 -ALLOWED_IMAGE_NAMES=[{"imageName":"texlive-full:2026.1","imageDesc":"2026", "alphaOnly": true},{"imageName":"texlive-full:2025.1","imageDesc":"2025"},{"imageName":"wl_texlive:2018.1","imageDesc":"2018"}] -ENABLE_PANDOC_CONVERSIONS=true -GOOGLE_VERTEX_PROJECT=overleaf-dev -GOOGLE_VERTEX_LOCATION=global diff --git a/services/web/docker-compose.cypress.yml b/services/web/docker-compose.cypress.yml deleted file mode 100644 index 47b7bf523b..0000000000 --- a/services/web/docker-compose.cypress.yml +++ /dev/null @@ -1,18 +0,0 @@ -services: - cypress: - build: - context: ../../dockerfiles/cypress - args: - - USER_UID - - USER_GID - volumes: - - ../../:/overleaf - - /tmp/.X11-unix:/tmp/.X11-unix - - ${XAUTHORITY:-/dev/null}:/home/node/.Xauthority - working_dir: /overleaf/services/web - entrypoint: yarn - command: run cypress:open-ct - environment: - VERBOSE_LOGGING: - DISPLAY: ${DISPLAY:-:0} -# DEBUG: cypress.* diff --git a/services/web/docker-compose.yml b/services/web/docker-compose.yml deleted file mode 100644 index be82f0432e..0000000000 --- a/services/web/docker-compose.yml +++ /dev/null @@ -1,191 +0,0 @@ -volumes: - data: - -services: - test_unit: - build: - context: ../.. - dockerfile: services/web/Dockerfile - target: base - volumes: - - .:/overleaf/services/web - - ../../node_modules:/overleaf/node_modules - - ../../libraries:/overleaf/libraries - - ../../bin/shared/wait_for_it:/overleaf/bin/shared/wait_for_it - - ../../tools/migrations:/overleaf/tools/migrations - - ../../tsconfig.backend.json:/overleaf/tsconfig.backend.json - - ../../package.json:/overleaf/package.json - - ../../.yarnrc.yml:/overleaf/.yarnrc.yml - - ../../yarn.lock:/overleaf/yarn.lock - - ../../.yarn:/overleaf/.yarn - working_dir: /overleaf/services/web - env_file: docker-compose.common.env - environment: - BASE_CONFIG: - OVERLEAF_CONFIG: - CI: - MODULE_NAME: - JUNIT_ROOT_SUITE_NAME: - COVERAGE_UNIT_TESTS: ${COVERAGE_UNIT_TESTS:-} - MOCHA_GREP: ${MOCHA_GREP:-} - LOG_LEVEL: ${LOG_LEVEL:-} - NODE_ENV: test - NODE_OPTIONS: "--unhandled-rejections=strict" - REDIS_HOST: redis_test - MONOREPO: - entrypoint: /overleaf/bin/shared/wait_for_it mongo:27017 --timeout=60 -- - command: yarn run --silent test:unit:app - user: node - depends_on: - - mongo - - redis_test - - test_acceptance: - build: - context: ../.. - dockerfile: services/web/Dockerfile - target: base - volumes: - - .:/overleaf/services/web - - ../../node_modules:/overleaf/node_modules - - ../../libraries:/overleaf/libraries - - ../../bin/shared/wait_for_it:/overleaf/bin/shared/wait_for_it - - ../../tools/migrations:/overleaf/tools/migrations - - ../../package.json:/overleaf/package.json - - ../../.yarnrc.yml:/overleaf/.yarnrc.yml - - ../../yarn.lock:/overleaf/yarn.lock - - ../../.yarn:/overleaf/.yarn - user: node - working_dir: /overleaf/services/web - env_file: docker-compose.common.env - environment: - BASE_CONFIG: - OVERLEAF_CONFIG: - CI: - MODULE_NAME: - JUNIT_ROOT_SUITE_NAME: - COVERAGE_ACCEPTANCE_TESTS: ${COVERAGE_ACCEPTANCE_TESTS:-} - MOCHA_GREP: ${MOCHA_GREP:-} - LOG_LEVEL: ${LOG_LEVEL:-} - MONGO_SERVER_SELECTION_TIMEOUT: 600000 - MONGO_SOCKET_TIMEOUT: 300000 - REDIS_HOST: redis_test - MONOREPO: - RETRIES: - # OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING: 'true' - - extra_hosts: - - "www.overleaf.test:127.0.0.1" - depends_on: - - redis_test - - mongo - - saml - - ldap - entrypoint: /overleaf/bin/shared/wait_for_it mongo:27017 --timeout=60 -- - command: yarn run --silent test:acceptance:app - - test_frontend_jsdom: - build: - context: ../.. - dockerfile: services/web/Dockerfile - target: base - volumes: - - .:/overleaf/services/web - - ../../node_modules:/overleaf/node_modules - - ../../libraries:/overleaf/libraries - - ../../package.json:/overleaf/package.json - - ../../.yarnrc.yml:/overleaf/.yarnrc.yml - - ../../yarn.lock:/overleaf/yarn.lock - - ../../.yarn:/overleaf/.yarn - working_dir: /overleaf/services/web - environment: - CI: - JUNIT_ROOT_SUITE_NAME: - MOCHA_GREP: ${MOCHA_GREP} - NODE_OPTIONS: "--unhandled-rejections=strict" - VERBOSE_LOGGING: - command: yarn run --silent test:frontend - user: node - - test_frontend_ct: - build: - context: ../../dockerfiles/cypress - args: - - USER_UID - - USER_GID - volumes: - - .:/overleaf/services/web - - ../../node_modules:/overleaf/node_modules - - ../../libraries:/overleaf/libraries - - ../../package.json:/overleaf/package.json - - ../../.yarnrc.yml:/overleaf/.yarnrc.yml - - ../../yarn.lock:/overleaf/yarn.lock - - ../../.yarn:/overleaf/.yarn - working_dir: /overleaf/services/web - environment: - VERBOSE_LOGGING: - CI: - JUNIT_ROOT_SUITE_NAME: - CYPRESS_SPEC_PATTERN: ${CYPRESS_SPEC_PATTERN:-} - CYPRESS_EXCLUDE_SPEC_PATTERN: ${CYPRESS_EXCLUDE_SPEC_PATTERN:-} - entrypoint: npm - command: - - "run" - - "cypress:run-ct" - - test_writefull: - build: - context: ../.. - dockerfile: services/web/Dockerfile - target: base - volumes: - - .:/overleaf/services/web - - ../../node_modules:/overleaf/node_modules - - ../../libraries:/overleaf/libraries - - ../../package.json:/overleaf/package.json - - ../../.yarnrc.yml:/overleaf/.yarnrc.yml - - ../../yarn.lock:/overleaf/yarn.lock - - ../../.yarn:/overleaf/.yarn - working_dir: /overleaf/services/web - environment: - CI: - JUNIT_ROOT_SUITE_NAME: - NODE_OPTIONS: "--unhandled-rejections=strict" - VERBOSE_LOGGING: - command: yarn run --silent test:writefull - user: node - - redis_test: - image: redis:7.4.8 - - mongo: - image: mongo:8.0.11 - command: --replSet overleaf --notablescan - volumes: - - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js - - ../../bin/shared/mongodb-docker-entrypoint-wait.sh:/mongodb-docker-entrypoint-wait.sh - entrypoint: /mongodb-docker-entrypoint-wait.sh - environment: - MONGO_INITDB_DATABASE: sharelatex - extra_hosts: - # Required when using the automatic database setup for initializing the - # replica set. This override is not needed when running the setup after - # starting up mongo. - - mongo:127.0.0.1 - - ldap: - image: rroemhild/test-openldap:1.1 - # Workaround large memory allocation (using the max-open-files-limit as socket buffer scale). - # REF: https://github.com/moby/moby/issues/8231#issuecomment-63877553 - # REF: https://github.com/moby/moby/issues/8231#issuecomment-63871343 - command: - - "bash" - - "-c" - - "ulimit -n 1024 && exec bash /run.sh" - - saml: - image: us-east1-docker.pkg.dev/overleaf-ops/ol-docker/saml-test - environment: - SAML_BASE_URL_PATH: "http://saml/simplesaml/" - SAML_TEST_SP_ENTITY_ID: "overleaf-test-saml" - SAML_TEST_SP_LOCATION: "http://www.overleaf.test:23000/saml/callback" diff --git a/services/web/eslint.config.mjs b/services/web/eslint.config.mjs deleted file mode 100644 index a94477242d..0000000000 --- a/services/web/eslint.config.mjs +++ /dev/null @@ -1,988 +0,0 @@ -// Shared ESLint flat config for the Overleaf web service. -// -// Mirrors what services/web/.eslintrc.js used to provide before the -// flat-config migration. - -import { defineConfig, globalIgnores } from 'eslint/config' -import tsParser from '@typescript-eslint/parser' -import overleaf from '@overleaf/eslint-plugin' -import globals from 'globals' -import mocha from 'eslint-plugin-mocha' -import chaiExpect from 'eslint-plugin-chai-expect' -import chaiFriendly from 'eslint-plugin-chai-friendly' -import vitest from '@vitest/eslint-plugin' -import unicorn from 'eslint-plugin-unicorn' -import cypress from 'eslint-plugin-cypress/flat' -import testingLibrary from 'eslint-plugin-testing-library' -import jsxA11Y from 'eslint-plugin-jsx-a11y' -import reactPlugin from 'eslint-plugin-react' -import reactHooks from 'eslint-plugin-react-hooks' -import storybook from 'eslint-plugin-storybook' -import importPlugin from 'eslint-plugin-import' -import n from 'eslint-plugin-n' -import promise from 'eslint-plugin-promise' -import typescriptEslint from '@typescript-eslint/eslint-plugin' -import js from '@eslint/js' -import json from '@eslint/json' -import prettier from 'eslint-config-prettier/flat' -import { fixupPluginRules } from '@eslint/compat' - -// eslint-plugin-import ships its configs in eslintrc shape; wrap with -// fixupPluginRules so its rules work in flat config. -const importPluginFixed = fixupPluginRules(importPlugin) - -// eslint-plugin-react 7.x still calls the removed `context.getFilename()` -// API; wrap with fixupPluginRules so the compat shim translates it under -// ESLint v10. Build a derivative flat-recommended config that references -// the wrapped plugin instead of the original. -const react = fixupPluginRules(reactPlugin) -const reactFlatRecommended = { - ...reactPlugin.configs.flat.recommended, - plugins: { react }, -} - -import _ from 'lodash' -import confusingBrowserGlobals from 'confusing-browser-globals' - -// Type-aware linting is expensive: setting `parserOptions.project` makes -// @typescript-eslint build a full TypeScript program from tsconfig.backend.json -// on every ESLint invocation, even when linting a single file. -// Set ESLINT_FAST=1 to skip type-aware checks; the full lint still runs in CI. -const TYPE_AWARE = process.env.ESLINT_FAST !== '1' - -export default defineConfig([ - // Declare which file extensions ESLint should consider in this workspace. - // Replaces the previous `--ext .js,.jsx,.mjs,.ts,.tsx` CLI flag, which is - // silently ignored under ESLint v9 + flat config. - { - files: ['**/*.{js,jsx,mjs,cjs,ts,tsx}'], - }, - { - // Scope JS/TS-specific config to JS/TS files only so that built-in - // rules like `no-irregular-whitespace` aren't applied to JSON files - // linted via @eslint/json elsewhere in this config. - files: ['**/*.{js,jsx,mjs,cjs,ts,tsx}'], - - languageOptions: { - parser: tsParser, - // Default to node globals for all backend-style files; the - // frontend block below replaces with browser globals where - // appropriate. Matches what the old monorepo-root .eslintrc - // used to provide via env: { node: true }, plus the three - // browser globals eslint-config-standard@17 added universally. - globals: { - ...globals.node, - document: 'readonly', - navigator: 'readonly', - window: 'readonly', - }, - }, - - // Match v8 default: don't flag stale `eslint-disable` directives. - linterOptions: { - reportUnusedDisableDirectives: 'off', - }, - - extends: [ - js.configs.recommended, - typescriptEslint.configs['flat/recommended'], - storybook.configs['flat/recommended'], - ], - - plugins: { - '@overleaf': overleaf, - '@typescript-eslint': typescriptEslint, - import: importPluginFixed, - // Registered (no rules enabled) so that pre-existing - // `eslint-disable n/handle-callback-err` (etc.) directives in - // the source still resolve to a real rule name. Under v8 these - // came in transitively via eslint-config-standard. - n, - promise, - react, - 'react-hooks': reactHooks, - 'jsx-a11y': jsxA11Y, - 'testing-library': testingLibrary, - cypress, - }, - - settings: { - // Tell eslint-plugin-react to detect which version of React we are using - react: { - version: 'detect', - }, - }, - - rules: { - 'no-constant-binary-expression': 'error', - 'no-restricted-globals': ['error', ...confusingBrowserGlobals], - - // do not allow importing of implicit dependencies. - 'import/no-extraneous-dependencies': 'error', - - '@overleaf/prefer-kebab-url': 'error', - '@overleaf/require-cio-snake-case-properties': 'error', - - // disable some TypeScript rules - '@typescript-eslint/no-var-requires': 'off', - '@typescript-eslint/no-unused-vars': 'off', - '@typescript-eslint/no-empty-function': 'off', - '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/no-this-alias': 'off', - '@typescript-eslint/no-non-null-assertion': 'off', - '@typescript-eslint/ban-ts-comment': 'off', - - 'no-use-before-define': 'off', - - '@typescript-eslint/no-use-before-define': [ - 'error', - { - functions: false, - classes: false, - variables: false, - }, - ], - - // The following three overrides preserve v8 behaviour the source - // tree inherited from eslint-config-standard@17. See the matching - // block in libraries/eslint-config/index.mjs for context. - 'no-unused-vars': [ - 'error', - { - args: 'none', - caughtErrors: 'none', - ignoreRestSiblings: true, - vars: 'all', - }, - ], - 'no-redeclare': ['error', { builtinGlobals: false }], - 'no-empty': ['error', { allowEmptyCatch: true }], - - // ESLint v10's eslint:recommended added these three rules. Disable - // to preserve the v9 zero-error baseline; revisit in a follow-up - // cleanup PR. - 'no-unassigned-vars': 'off', - 'no-useless-assignment': 'off', - 'preserve-caught-error': 'off', - }, - }, - // NOTE: changing paths may require updating them in the Makefile too. - { - // Node - files: [ - '**/app/src/**/*.{js,mjs}', - 'app.{js,mjs}', - 'i18next-scanner.config.js', - 'scripts/**/*.{js,mjs}', - 'webpack.config*.js', - ], - - languageOptions: { - globals: { - ...globals.node, - }, - }, - }, - { - // Test specific rules - files: ['**/test/**/*.{js,jsx,mjs,cjs,ts,tsx}'], - ignores: ['**/test/unit/src/**/*.test.mjs', 'test/unit/bootstrap.mjs'], // exclude vitest files - - plugins: { - mocha, - 'chai-expect': chaiExpect, - 'chai-friendly': chaiFriendly, - }, - - languageOptions: { - globals: { - ...globals.mocha, - }, - }, - - rules: { - // mocha-specific rules - 'mocha/handle-done-callback': 'error', - 'mocha/no-exclusive-tests': 'error', - 'mocha/no-global-tests': 'error', - 'mocha/no-identical-title': 'error', - 'mocha/no-nested-tests': 'error', - 'mocha/no-pending-tests': 'error', - 'mocha/no-mocha-arrows': 'error', - - // Swap the no-unused-expressions rule with a more chai-friendly one - 'no-unused-expressions': 'off', - 'chai-friendly/no-unused-expressions': 'error', - - // chai-specific rules - 'chai-expect/missing-assertion': 'error', - 'chai-expect/terminating-properties': 'error', - - // prefer-arrow-callback applies to all callbacks, not just ones in mocha tests. - // we don't enforce this at the top-level - just in tests to manage `this` scope - // based on mocha's context mechanism - 'mocha/prefer-arrow-callback': 'error', - - '@typescript-eslint/no-unused-expressions': 'off', - }, - }, - { - files: ['**/test/unit/src/**/*.test.mjs', 'test/unit/bootstrap.mjs'], - - languageOptions: { - globals: { - ...globals.jest, // best match for vitest API etc. - }, - }, - - plugins: { - '@vitest': vitest, - 'chai-expect': chaiExpect, - 'chai-friendly': chaiFriendly, // still using chai for now - }, - - rules: { - // vitest-specific rules - '@vitest/no-focused-tests': 'error', - '@vitest/no-disabled-tests': 'error', - - // Swap the no-unused-expressions rule with a more chai-friendly one - 'no-unused-expressions': 'off', - 'chai-friendly/no-unused-expressions': 'error', - - // chai-specific rules - 'chai-expect/missing-assertion': 'error', - 'chai-expect/terminating-properties': 'error', - '@typescript-eslint/no-unused-expressions': 'off', - '@overleaf/require-vi-doMock-valid-path': 'error', - }, - }, - { - // ES specific rules - files: [ - '**/app/src/**/*.mjs', - 'modules/*/index.mjs', - 'app.mjs', - 'scripts/**/*.mjs', - 'migrations/**/*.mjs', - '**/test/acceptance/src/**/*.mjs', - '**/test/unit/src/**/*.mjs', - ], - - ignores: [ - // migration template file - 'migrations/lib/template.mjs', - ], - - languageOptions: { - sourceType: 'module', - parserOptions: {}, - }, - - plugins: { - unicorn, - }, - - rules: { - 'import/no-unresolved': [ - 'error', - { - // eslint-plugin-import does not support exports directive in package.json - // https://github.com/import-js/eslint-plugin-import/issues/1810 - ignore: ['^p-queue$'], - }, - ], - - 'import/named': 'error', - 'import/default': 'error', - - 'import/extensions': [ - 'error', - 'ignorePackages', - { - js: 'always', - mjs: 'always', - }, - ], - - 'unicorn/prefer-module': 'error', - 'unicorn/prefer-node-protocol': 'error', - }, - }, - { - // Backend specific rules - files: ['**/app/src/**/*.{js,mjs}', 'app.{js,mjs}'], - - languageOptions: { - parserOptions: TYPE_AWARE - ? { - tsconfigRootDir: import.meta.dirname, - project: './tsconfig.backend.json', - } - : {}, - }, - - rules: { - // do not allow importing of implicit dependencies. - 'import/no-extraneous-dependencies': [ - 'error', - { - // do not allow importing of devDependencies. - devDependencies: false, - }, - ], - - 'no-restricted-syntax': [ - 'error', - // do not allow node-fetch in backend code - { - selector: - "CallExpression[callee.name='require'] > .arguments[value='node-fetch']", - message: - 'Requiring node-fetch is not allowed in production services, please use fetch-utils.', - }, - // mongoose populate must set fields to populate - { - selector: - "CallExpression[callee.property.name='populate'][arguments.length<2]", - message: - "Populate without a second argument returns the whole document. Use populate('field',['prop1','prop2']) instead", - }, - // Require `new` when constructing ObjectId (For mongo + mongoose upgrade) - { - selector: - "CallExpression[callee.name='ObjectId'], CallExpression[callee.property.name='ObjectId']", - message: - 'Construct ObjectId with `new ObjectId()` instead of `ObjectId()`', - }, - // Require `new` when mapping a list of ids to a list of ObjectId (For mongo + mongoose upgrade) - { - selector: - "CallExpression[callee.property.name='map'] Identifier[name='ObjectId']:first-child, CallExpression[callee.property.name='map'] MemberExpression[property.name='ObjectId']:first-child", - message: - "Don't map ObjectId directly. Use `id => new ObjectId(id)` instead", - }, - // Catch incorrect usage of `await db.collection.find()` - { - selector: - "AwaitExpression > CallExpression > MemberExpression[property.name='find'][object.object.name='db']", - message: - 'Mongo find returns a cursor not a promise, use `for await (const result of cursor)` or `.toArray()` instead.', - }, - ], - - '@typescript-eslint/no-floating-promises': TYPE_AWARE - ? [ - 'error', - { - checkThenables: true, - }, - ] - : 'off', - }, - }, - { - // Backend scripts specific rules - files: ['**/scripts/**/*.{js,mjs}'], - - rules: { - 'no-restricted-syntax': [ - 'error', - // Require `new` when constructing ObjectId (For mongo + mongoose upgrade) - { - selector: - "CallExpression[callee.name='ObjectId'], CallExpression[callee.property.name='ObjectId']", - message: - 'Construct ObjectId with `new ObjectId()` instead of `ObjectId()`', - }, - // Require `new` when mapping a list of ids to a list of ObjectId (For mongo + mongoose upgrade) - { - selector: - "CallExpression[callee.property.name='map'] Identifier[name='ObjectId']:first-child, CallExpression[callee.property.name='map'] MemberExpression[property.name='ObjectId']:first-child", - message: - "Don't map ObjectId directly. Use `id => new ObjectId(id)` instead", - }, - // Catch incorrect usage of `await db.collection.find()` - { - selector: - "AwaitExpression > CallExpression > MemberExpression[property.name='find'][object.object.name='db']", - message: - 'Mongo find returns a cursor not a promise, use `for await (const result of cursor)` or `.toArray()` instead.', - }, - ], - }, - }, - { - // Insist on using Script Runner for new scripts. Old scripts should be - // converted to use Script Runner in future, but are excluded for now. - rules: { - '@overleaf/require-script-runner': 'error', - }, - - files: ['**/scripts/**/*.mjs'], // ESM only - - ignores: [ - 'modules/admin-roles/scripts/import_admin_role_assignments.mjs', - 'modules/admin-roles/scripts/remove_admin_role_from_user.mjs', - 'modules/admin-roles/scripts/remove_admin_roles_from_non_admins.mjs', - 'modules/admin-roles/scripts/utils.mjs', - 'modules/institutions/scripts/apply_policy_to_institution.mjs', - 'modules/server-ce-scripts/scripts/change-compile-timeout.mjs', - 'modules/server-ce-scripts/scripts/check-mongodb.mjs', - 'modules/server-ce-scripts/scripts/check-redis.mjs', - 'modules/server-ce-scripts/scripts/check-texlive-images.mjs', - 'modules/server-ce-scripts/scripts/create-user.mjs', - 'modules/server-ce-scripts/scripts/delete-user.mjs', - 'modules/server-ce-scripts/scripts/export-user-projects.mjs', - 'modules/server-ce-scripts/scripts/migrate-user-emails.mjs', - 'modules/server-ce-scripts/scripts/rename-tag.mjs', - 'modules/server-ce-scripts/scripts/transfer-all-projects-to-user.mjs', - 'modules/server-ce-scripts/scripts/upgrade-user-features.mjs', - 'modules/subscriptions/scripts/backfill_user_last_trial.mjs', - 'scripts/add_feature_override.mjs', - 'scripts/add_subscription_members_csv.mjs', - 'scripts/analytics/helpers/GoogleBigQueryHelper.mjs', - 'scripts/attach_dangling_comments_to_doc.mjs', - 'scripts/backfill_mixpanel_user_properties.mjs', - 'scripts/backfill_project_image_name.mjs', - 'scripts/backfill_user_properties.mjs', - 'scripts/backfill_users_sso_attribute.mjs', - 'scripts/bench_bcrypt.mjs', - 'scripts/check_institution_users.mjs', - 'scripts/check_overleafModuleImports.mjs', - 'scripts/check_saml_emails.mjs', - 'scripts/clear_feedback_collection.mjs', - 'scripts/clear_sessions_set_must_reconfirm.mjs', - 'scripts/count_files_in_projects.mjs', - 'scripts/count_project_size.mjs', - 'scripts/create_oauth_personal_access_token.mjs', - 'scripts/create_project.mjs', - 'scripts/deactivate_projects.mjs', - 'scripts/delete-duplicate-splittest-versions/delete_test_dupes.mjs', - 'scripts/delete-orphaned-docs/delete-orphaned-docs.mjs', - 'scripts/delete_dangling_comments.mjs', - 'scripts/delete_orphaned_chat_threads.mjs', - 'scripts/delete_orphaned_data_helper.mjs', - 'scripts/delete_subscriptions.mjs', - 'scripts/devcontainer_setup.mjs', - 'scripts/e2e_test_setup.mjs', - 'scripts/ensure_affiliations.mjs', - 'scripts/esm-check-migration.mjs', - 'scripts/example/script_for_migration.mjs', - 'scripts/fix_collaborator_refs_null.mjs', - 'scripts/fix_comment_id.mjs', - 'scripts/helpers/chunkArray.mjs', - 'scripts/helpers/env_variable_helper.mjs', - 'scripts/inst_table.mjs', - 'scripts/invalidate_tokens.mjs', - 'scripts/ip_matcher_ranges.mjs', - 'scripts/learn/checkSanitize/checkSanitizeOptions.mjs', - 'scripts/learn/checkSanitize/scrape.mjs', - 'scripts/lezer-latex/benchmark.mjs', - 'scripts/lezer-latex/print-tree.mjs', - 'scripts/lezer-latex/random.mjs', - 'scripts/lezer-latex/run.mjs', - 'scripts/lezer-latex/test-incremental-parser.mjs', - 'scripts/mark_migration.mjs', - 'scripts/marketing-exports/error-assistant-export.mjs', - 'scripts/marketing-exports/export.mjs', - 'scripts/marketing-exports/linked-papers-users.mjs', - 'scripts/marketing-exports/papers-export.mjs', - 'scripts/marketing-exports/writefull-export.mjs', - 'scripts/oauth/upgrade_token_scopes.mjs', - 'scripts/plan-prices/plans.mjs', - 'scripts/process_lapsed_reconfirmations.mjs', - 'scripts/purge_non_logged_in_sessions.mjs', - 'scripts/recurly/generate_recurly_prices.mjs', - 'scripts/recurly/get_paypal_accounts_csv.mjs', - 'scripts/recurly/recurly_prices.mjs', - 'scripts/recurly/resync_recurly_state_single_subscription.mjs', - 'scripts/recurly/resync_subscriptions.mjs', - 'scripts/recurly/set_manually_collected_subscriptions.mjs', - 'scripts/refresh_features.mjs', - 'scripts/regenerate_duplicate_referral_ids.mjs', - 'scripts/remove_deleted_users_from_token_access_refs.mjs', - 'scripts/remove_email.mjs', - 'scripts/remove_user_enrollment.mjs', - 'scripts/sso_id_migration_check.mjs', - 'scripts/stress_test.mjs', - 'scripts/suspend_users.mjs', - 'scripts/sync-user-entitlements/sync-user-entitlements.mjs', - 'scripts/update_project_image_name.mjs', - 'scripts/user-export/analytics.mjs', - 'scripts/user-export/fs.mjs', - 'scripts/user-export/http.mjs', - 'scripts/user-export/observer.mjs', - 'scripts/user-export/options.mjs', - 'scripts/user-export/project.mjs', - 'scripts/user-export/scrubber.mjs', - 'scripts/user-export/stream.mjs', - 'scripts/user-export/user.mjs', - 'scripts/validate-data-of-model.mjs', - ], - }, - { - // Cypress specific rules - files: [ - 'cypress/**/*.{js,jsx,ts,tsx}', - '**/test/frontend/**/*.spec.{js,jsx,ts,tsx}', - ], - - plugins: { - cypress, - }, - - rules: { - ...cypress.configs.recommended.rules, - }, - }, - { - // Frontend test specific rules - files: ['**/frontend/**/*.test.{js,jsx,ts,tsx}'], - - plugins: { - 'testing-library': testingLibrary, - }, - - rules: { - ...testingLibrary.configs['flat/react'].rules, - 'testing-library/no-await-sync-events': 'off', - 'testing-library/no-await-sync-queries': 'off', - 'testing-library/no-container': 'off', - 'testing-library/no-node-access': 'off', - 'testing-library/no-render-in-lifecycle': 'off', - 'testing-library/no-wait-for-multiple-assertions': 'off', - 'testing-library/no-wait-for-side-effects': 'off', - 'testing-library/prefer-query-by-disappearance': 'off', - 'testing-library/prefer-screen-queries': 'off', - 'testing-library/render-result-naming-convention': 'off', - }, - }, - { - // Frontend specific rules - files: [ - '**/frontend/js/**/*.{js,jsx,ts,tsx}', - '**/frontend/stories/**/*.{js,jsx,ts,tsx}', - '**/*.stories.{js,jsx,ts,tsx}', - '**/test/frontend/**/*.{js,jsx,ts,tsx}', - '**/test/frontend/components/**/*.spec.{js,jsx,ts,tsx}', - ], - - languageOptions: { - globals: { - ...globals.browser, - __webpack_public_path__: true, - $: true, - ga: true, - }, - - sourceType: 'module', - parserOptions: {}, - }, - - plugins: { - react, - 'react-hooks': reactHooks, - 'jsx-a11y': jsxA11Y, - }, - - rules: { - ...reactFlatRecommended.rules, - ...reactHooks.configs['recommended-latest'].rules, - ...jsxA11Y.flatConfigs.recommended.rules, - 'react-hooks/exhaustive-deps': [ - 'warn', - { - additionalHooks: '(useCommandProvider)', - }, - ], - // TODO: remove once https://github.com/standard/eslint-config-standard-react/issues/68 (support eslint@8) is fixed. - // START: inline standard-react rules - // "react/jsx-no-bind": ["error", { - // "allowArrowFunctions": true, - // "allowBind": false, - // "ignoreRefs": true - // },], - 'react/no-did-update-set-state': 'error', - 'react/no-unused-prop-types': 'error', - 'react/prop-types': 'error', - '@overleaf/no-generated-editor-themes': 'error', - // "react/react-in-jsx-scope": "error", - // END: inline standard-react rules - - // eslint-plugin-react 7.37 enabled `react/no-unescaped-entities` - // in plugin:react/recommended. v8 (with 7.32) didn't. Disable to - // match v8 -- the source uses literal apostrophes and quotes in - // JSX freely. - 'react/no-unescaped-entities': 'off', - - 'react/no-unknown-property': [ - 'error', - { - ignore: ['dnd-container', 'dropdown-toggle'], - }, - ], - - 'react/jsx-no-target-blank': [ - 'error', - { - allowReferrer: true, - }, - ], - - // Prevent usage of legacy string refs - 'react/no-string-refs': 'error', - - // Prevent curly braces around strings (as they're unnecessary) - 'react/jsx-curly-brace-presence': [ - 'error', - { - props: 'never', - children: 'never', - }, - ], - - // Don't import React for JSX; the JSX runtime is added by a Babel plugin - 'react/react-in-jsx-scope': 'off', - 'react/jsx-uses-react': 'off', - - // Allow functions as JSX props - 'react/jsx-no-bind': 'off', // TODO: fix occurrences and re-enable this - - // Fix conflict between prettier & standard by overriding to prefer - // double quotes - 'jsx-quotes': ['error', 'prefer-double'], - - // Override weird behaviour of jsx-a11y label-has-for (says labels must be - // nested *and* have for/id attributes) - 'jsx-a11y/label-has-for': [ - 'error', - { - required: { - some: ['nesting', 'id'], - }, - }, - ], - - // Require .jsx or .tsx file extension when using JSX - 'react/jsx-filename-extension': [ - 'error', - { - extensions: ['.jsx', '.tsx'], - }, - ], - - 'no-restricted-syntax': [ - 'error', - // prohibit direct calls to methods of window.localStorage - { - selector: - "CallExpression[callee.object.object.name='window'][callee.object.property.name='localStorage']", - message: - 'Modify location via customLocalStorage instead of calling window.localStorage methods directly', - }, - ], - - 'no-unused-vars': 'off', - - '@typescript-eslint/no-unused-vars': [ - 'error', - { - args: 'after-used', - argsIgnorePattern: '^_', - ignoreRestSiblings: false, - caughtErrors: 'none', - vars: 'all', - varsIgnorePattern: '^_', - }, - ], - }, - }, - { - // Sorting for Meta - files: ['frontend/js/utils/meta.ts'], - - rules: { - '@typescript-eslint/member-ordering': [ - 'error', - { - interfaces: { - order: 'alphabetically', - }, - }, - ], - }, - }, - { - // React component specific rules - // - files: [ - '**/frontend/js/**/components/**/*.{js,jsx,ts,tsx}', - '**/frontend/js/**/hooks/**/*.{js,jsx,ts,tsx}', - ], - - rules: { - '@overleaf/no-unnecessary-trans': 'error', - '@overleaf/should-unescape-trans': 'error', - '@overleaf/require-loading-label': 'error', - - // https://astexplorer.net/ - 'no-restricted-syntax': [ - 'error', - // prohibit direct calls to methods of window.location - { - selector: - "CallExpression[callee.object.object.name='window'][callee.object.property.name='location']", - message: - 'Modify location via useLocation instead of calling window.location methods directly', - }, - // prohibit assignment to window.location - { - selector: - "AssignmentExpression[left.object.name='window'][left.property.name='location']", - message: - 'Modify location via useLocation instead of calling window.location methods directly', - }, - // prohibit assignment to window.location.href - { - selector: - "AssignmentExpression[left.object.object.name='window'][left.object.property.name='location'][left.property.name='href']", - message: - 'Modify location via useLocation instead of calling window.location methods directly', - }, - // prohibit using lookbehinds due to incidents with Safari simply crashing when the script is parsed - { - selector: 'Literal[regex.pattern=/\\(\\?<[!=]/]', - message: 'Lookbehind is not supported in older Safari versions.', - }, - // prohibit direct calls to methods of window.localStorage - // NOTE: this rule is also defined for all frontend files, but those rules are overriden by the React component-specific config - { - selector: - "CallExpression[callee.object.object.name='window'][callee.object.property.name='localStorage']", - message: - 'Modify location via customLocalStorage instead of calling window.localStorage methods directly', - }, - ], - }, - }, - // React + TypeScript-specific rules - { - files: ['**/*.tsx'], - - rules: { - 'react/prop-types': 'off', - 'no-undef': 'off', - }, - }, - // TypeScript-specific rules - { - files: ['**/*.ts'], - - rules: { - 'no-undef': 'off', - }, - }, - // JavaScript-specific rules - { - files: ['**/*.js'], - - rules: { - '@typescript-eslint/no-require-imports': 'off', - }, - }, - - { - files: ['scripts/ukamf/*.js'], - rules: { - // Do not allow importing of any dependencies unless specified in either - // - web/package.json - // - web/scripts/ukamf/package.json - 'import/no-extraneous-dependencies': [ - 'error', - { - packageDir: ['.', 'scripts/ukamf'], - }, - ], - }, - }, - { - files: ['scripts/learn/checkSanitize/*.js'], - - rules: { - // The checkSanitize script is used in the dev-env only. - 'import/no-extraneous-dependencies': [ - 'error', - { - devDependencies: true, - packageDir: ['.', '../../'], - }, - ], - }, - }, - { - files: [ - // Backend: Use @overleaf/logger - // Docs: https://manual.dev-overleaf.com/development/code/logging/#structured-logging - '**/app/**/*.{js,cjs,mjs}', - 'app.{js,mjs}', - 'modules/*/*.{js,mjs}', - // Frontend: Prefer debugConsole over bare console - // Docs: https://manual.dev-overleaf.com/development/code/logging/#frontend - '**/frontend/**/*.{js,jsx,ts,tsx}', - // Tests - '**/test/**/*.{js,cjs,mjs,jsx,ts,tsx}', - ], - - ignores: [ - // Allow console logs in scripts - '**/scripts/**/*.js', - // Allow console logs in stories - '**/stories/**/*.{js,jsx,ts,tsx}', - // Workers do not have access to the search params for enabling ?debug=true. - // self.location.url is the URL of the worker script. - '**/*.worker.{js,ts}', - ], - - rules: { - 'no-console': 'error', - }, - }, - { - files: ['**/*.worker.{js,ts}'], - - rules: { - 'no-restricted-globals': [ - 'error', - ..._.difference( - Object.keys({ - ...globals.browser, - ...globals.node, - }), - Object.keys(globals.worker) - ), - ], - }, - }, - { - // The writefull module ships from upstream as a vendored - // integration; its style/test conventions differ from the rest - // of services/web. Under v8 these files lint-passed in CI even - // though the surface area would normally trip several rules -- - // suggesting either historical exemption or pre-existing CI - // tolerance. To match the user-reported v8 zero-error baseline - // without touching the imported source, disable the rules that - // fire here. - files: ['modules/writefull/**/*.{js,jsx,ts,tsx,mjs,cjs}'], - rules: { - 'no-console': 'off', - '@typescript-eslint/no-unused-vars': 'off', - 'no-unused-vars': 'off', - 'react/jsx-curly-brace-presence': 'off', - 'react/jsx-no-target-blank': 'off', - 'react/no-unused-prop-types': 'off', - 'react/no-deprecated': 'off', - 'react-hooks/rules-of-hooks': 'off', - '@overleaf/no-generated-editor-themes': 'off', - // jsx-a11y/* fires on many writefull components. Disable the - // ones that surface here to match the v8 zero-error baseline. - 'jsx-a11y/no-static-element-interactions': 'off', - 'jsx-a11y/click-events-have-key-events': 'off', - 'jsx-a11y/no-autofocus': 'off', - 'jsx-a11y/label-has-for': 'off', - 'jsx-a11y/role-supports-aria-props': 'off', - 'jsx-a11y/anchor-is-valid': 'off', - 'jsx-a11y/interactive-supports-focus': 'off', - }, - }, - { - // ESLint v9's prefer-const analysis on TypeScript destructuring - // (`let { foo, bar } = ...`) fires where v8 did not, even with - // identical `destructuring: 'all'` options. Disable to match v8 - // behaviour on the existing TS source -- the actual let-vs-const - // intent is preserved by the source code. - files: ['**/*.{ts,tsx}'], - rules: { - 'prefer-const': 'off', - }, - }, - { - // eslint-plugin-testing-library bumped from 7.1 to 7.5 in the - // migration; the newer release enabled / tightened several rules - // that fire on pre-existing test code. Disable to preserve v8 - // behaviour; revisit in a follow-up cleanup PR. - files: ['**/frontend/**/*.test.{js,jsx,ts,tsx}'], - rules: { - 'testing-library/no-debugging-utils': 'off', - 'testing-library/prefer-presence-queries': 'off', - 'testing-library/no-manual-cleanup': 'off', - }, - }, - { - // Frontend test files import chai's `use()` and call it at module - // top level; eslint-plugin-react-hooks v5 heuristically treats any - // `use*` name as a React Hook and flags this as rules-of-hooks. - // Pre-existing v8 behaviour (react-hooks v4) was more conservative; - // disable for test files to match. - // react/no-deprecated also fires on test files using - // ReactDOM.unmountComponentAtNode and similar React-18-deprecated - // APIs; v8 effectively tolerated these (the user-reported zero - // baseline). Disable for test files. - files: [ - '**/test/frontend/**/*.{js,jsx,ts,tsx}', - '**/frontend/**/*.test.{js,jsx,ts,tsx}', - '**/frontend/**/*.spec.{js,jsx,ts,tsx}', - ], - rules: { - 'react-hooks/rules-of-hooks': 'off', - 'react/no-deprecated': 'off', - }, - }, - { - // eslint.config.mjs itself imports eslint plugins as devDependencies; - // allow that since this file is part of the tooling, not the app. - files: ['eslint.config.mjs'], - rules: { - 'import/no-extraneous-dependencies': ['error', { devDependencies: true }], - }, - }, - // eslint-config-prettier disables stylistic rules that conflict with - // prettier formatting. Placed last so it overrides rules pulled in by - // preceding configs (eslint:recommended, @typescript-eslint, react, - // jsx-a11y, ...). - prettier, - { - // Lint locale JSON files for typographic and i18n conventions. - files: ['locales/*.json'], - language: 'json/json', - plugins: { json, '@overleaf': overleaf }, - rules: { - '@overleaf/no-consecutive-spaces-in-locales': 'error', - '@overleaf/no-straight-apostrophes-in-locales': 'error', - '@overleaf/sorted-keys-in-locales': 'error', - '@overleaf/locale-variables-match-en': 'error', - '@overleaf/no-orphan-locale-keys': 'error', - }, - }, - { - files: ['locales/fr.json'], - language: 'json/json', - plugins: { json, '@overleaf': overleaf }, - rules: { - '@overleaf/french-typography-in-locales': 'error', - }, - }, - globalIgnores([ - '**/data/', - 'scripts/translations/.cache/', - '**/node_modules', - 'frontend/js/vendor', - 'modules/**/frontend/js/vendor', - 'public/', - 'frontend/js/features/source-editor/lezer-latex/latex.mjs', - 'frontend/js/features/source-editor/lezer-latex/latex.terms.mjs', - 'frontend/js/features/source-editor/lezer-bibtex/bibtex.mjs', - 'frontend/js/features/source-editor/lezer-bibtex/bibtex.terms.mjs', - 'frontend/js/features/source-editor/hunspell/wasm/hunspell.mjs', - ]), -]) diff --git a/services/web/frontend/stories/chat.stories.jsx b/services/web/frontend/stories/chat.stories.jsx deleted file mode 100644 index 73b207ac74..0000000000 --- a/services/web/frontend/stories/chat.stories.jsx +++ /dev/null @@ -1,58 +0,0 @@ -import ChatPane from '../js/features/chat/components/chat-pane' -import useFetchMock from './hooks/use-fetch-mock' -import { generateMessages } from './fixtures/chat-messages' -import { ScopeDecorator } from './decorators/scope' -import { UserProvider } from '@/shared/context/user-context' - -export const Conversation = args => { - useFetchMock(fetchMock => { - fetchMock.get(/messages/, generateMessages(35)).post(/messages/, {}) - }) - - return -} - -export const NoMessages = args => { - useFetchMock(fetchMock => { - fetchMock.get(/messages/, []) - }) - - return -} - -export const Loading = args => { - useFetchMock(fetchMock => { - fetchMock.get(/messages/, generateMessages(6), { - delay: 1000 * 10, - }) - }) - - return -} - -export const LoadingError = args => { - useFetchMock(fetchMock => { - fetchMock.get(/messages/, 500) - }) - - return -} - -export default { - title: 'Editor / Chat', - component: ChatPane, - argTypes: { - resetUnreadMessages: { action: 'resetUnreadMessages' }, - }, - args: { - resetUnreadMessages: () => {}, - }, - decorators: [ - ScopeDecorator, - Story => ( - - - - ), - ], -} diff --git a/services/web/frontend/stories/clone-project-modal.stories.jsx b/services/web/frontend/stories/clone-project-modal.stories.jsx deleted file mode 100644 index 843a18f0be..0000000000 --- a/services/web/frontend/stories/clone-project-modal.stories.jsx +++ /dev/null @@ -1,61 +0,0 @@ -import useFetchMock from './hooks/use-fetch-mock' -import CloneProjectModal from '../js/features/clone-project-modal/components/clone-project-modal' -import { ScopeDecorator } from './decorators/scope' - -export const Success = args => { - useFetchMock(fetchMock => { - fetchMock.post( - 'express:/project/:projectId/clone', - { status: 200 }, - { delay: 250 } - ) - }) - - return -} - -export const GenericErrorResponse = args => { - useFetchMock(fetchMock => { - fetchMock.post( - 'express:/project/:projectId/clone', - { status: 500 }, - { delay: 250 } - ) - }) - - return -} - -export const SpecificErrorResponse = args => { - useFetchMock(fetchMock => { - fetchMock.post( - 'express:/project/:projectId/clone', - { status: 400, body: 'The project name is not valid' }, - { delay: 250 } - ) - }) - - return -} - -export default { - title: 'Editor / Modals / Clone Project', - component: CloneProjectModal, - args: { - show: true, - projectName: 'Project 1', - projectTags: [ - { - _id: 'tag-1', - name: 'Category 1', - color: '#c0ffee', - }, - ], - }, - argTypes: { - handleHide: { action: 'close modal' }, - openProject: { action: 'open project' }, - handleAfterCloned: { action: 'after cloned' }, - }, - decorators: [ScopeDecorator], -} diff --git a/services/web/frontend/stories/contact-us-modal.stories.tsx b/services/web/frontend/stories/contact-us-modal.stories.tsx deleted file mode 100644 index 061ef8997b..0000000000 --- a/services/web/frontend/stories/contact-us-modal.stories.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { ComponentProps, useCallback, useState } from 'react' -import useFetchMock from './hooks/use-fetch-mock' -import ContactUsModal from '../../modules/support/frontend/js/components/contact-us-modal' -import fixedHelpSuggestionSearch from '../../modules/support/test/frontend/util/fixed-help-suggestion-search' -import { ScopeDecorator } from './decorators/scope' -import OLButton from '@/shared/components/ol/ol-button' - -type ContactUsModalProps = ComponentProps - -function GenericContactUsModal(args: ContactUsModalProps) { - useFetchMock(fetchMock => { - fetchMock.post('/support', { status: 200 }, { delay: 1000 }) - }) - - return ( - - ) -} - -export const Generic = (args: ContactUsModalProps) => ( - -) - -const ContactUsModalWithRequestError = (args: ContactUsModalProps) => { - useFetchMock(fetchMock => { - fetchMock.post('/support', { status: 404 }, { delay: 250 }) - }) - - return ( - - ) -} - -export const RequestError = (args: ContactUsModalProps) => ( - -) - -const ContactUsModalWithAcknowledgement = ( - args: Omit -) => { - useFetchMock(fetchMock => { - fetchMock.post('/support', { status: 200 }, { delay: 1000 }) - }) - - const [show, setShow] = useState(false) - - const hideModal = useCallback((event?: Event) => { - event?.preventDefault() - setShow(false) - }, []) - - return ( - <> - setShow(true)}>Contact Us - - - ) -} - -export const WithAcknowledgement = (args: ContactUsModalProps) => { - const { show: _show, handleHide: _handleHide, ...rest } = args - return -} - -export default { - title: 'Shared / Modals / Contact Us', - component: ContactUsModal, - args: { - show: true, - handleHide: () => {}, - autofillProjectUrl: true, - }, - argTypes: { - handleHide: { action: 'close modal' }, - }, - decorators: [ScopeDecorator], -} diff --git a/services/web/frontend/stories/decorators/ide-redesign.tsx b/services/web/frontend/stories/decorators/ide-redesign.tsx deleted file mode 100644 index d0708790d8..0000000000 --- a/services/web/frontend/stories/decorators/ide-redesign.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import { Decorator } from '@storybook/react-webpack5' - -export const IdeRedesign: Decorator = Story => ( -
- -
-) diff --git a/services/web/frontend/stories/decorators/scope.tsx b/services/web/frontend/stories/decorators/scope.tsx deleted file mode 100644 index 85785743d0..0000000000 --- a/services/web/frontend/stories/decorators/scope.tsx +++ /dev/null @@ -1,199 +0,0 @@ -import React, { FC, useEffect, useState } from 'react' -import { User, UserId } from '../../../types/user' -import { Project } from '../../../types/project' -import { - mockBuildFile, - mockCompile, - mockCompileError, -} from '../fixtures/compile' -import useFetchMock from '../hooks/use-fetch-mock' -import { useMeta } from '../hooks/use-meta' -import SocketIOShim, { SocketIOMock } from '@/ide/connection/SocketIoShim' -import { IdeContext } from '@/shared/context/ide-context' -import { IdeReactContext } from '@/features/ide-react/context/ide-react-context' -import { IdeEventEmitter } from '@/features/ide-react/create-ide-event-emitter' -import { ReactScopeValueStore } from '@/features/ide-react/scope-value-store/react-scope-value-store' -import { ReactScopeEventEmitter } from '@/features/ide-react/scope-event-emitter/react-scope-event-emitter' -import { ConnectionContext } from '@/features/ide-react/context/connection-context' -import { Socket } from '@/features/ide-react/connection/types/socket' -import { ConnectionState } from '@/features/ide-react/connection/types/connection-state' -import { ReactContextRoot } from '@/features/ide-react/context/react-context-root' - -const scopeWatchers: [string, (value: any) => void][] = [] - -export const user: User = { - id: 'story-user' as UserId, - email: 'story-user@example.com', - allowedFreeTrial: true, - features: { dropbox: true, symbolPalette: true }, -} - -const project: Project = { - _id: '63e21c07946dd8c76505f85a', - name: 'A Project', - features: { mendeley: true, zotero: true, referencesSearch: true }, - tokens: {}, - owner: { - _id: 'a-user', - email: 'stories@overleaf.com', - }, - members: [], - invites: [], - rootDocId: '5e74f1a7ce17ae0041dfd056', - rootFolder: [ - { - _id: 'root-folder-id', - name: 'rootFolder', - docs: [ - { _id: 'test-file-id', name: 'testfile.tex' }, - { _id: 'test-bib-file-id', name: 'testsources.bib' }, - ], - fileRefs: [{ _id: 'test-image-id', name: 'frog.jpg', hash: '42' }], - folders: [], - }, - ], -} - -const socket = new SocketIOShim.SocketShimNoop( - new SocketIOMock() -) as unknown as Socket - -const initializeMetaTags = () => { - // window.metaAttributesCache is reset in preview.tsx - window.metaAttributesCache.set('ol-user', user) - window.metaAttributesCache.set('ol-project_id', project._id) - window.metaAttributesCache.set( - 'ol-gitBridgePublicBaseUrl', - 'https://git.stories.com' - ) -} - -type ScopeDecoratorOptions = { - mockCompileOnLoad: boolean - providers?: Record -} - -export const ScopeDecorator = ( - Story: any, - opts: ScopeDecoratorOptions = { mockCompileOnLoad: true }, - meta: Record = {} -) => { - initializeMetaTags() - - // mock compile on load - useFetchMock(fetchMock => { - if (opts.mockCompileOnLoad) { - mockCompile(fetchMock) - mockCompileError(fetchMock) - mockBuildFile(fetchMock) - } - }) - - // clear scopeWatchers on unmount - useEffect(() => { - return () => { - scopeWatchers.length = 0 - } - }, []) - - // set values on window.metaAttributesCache (created in initialize, above) - useMeta(meta) - - return ( - - - - ) -} - -const ConnectionProvider: FC = ({ children }) => { - const [value] = useState(() => { - const connectionState: ConnectionState = { - readyState: WebSocket.OPEN, - forceDisconnected: false, - inactiveDisconnect: false, - reconnectAt: null, - forcedDisconnectDelay: 0, - lastConnectionAttempt: 0, - error: '', - } - return { - socket, - connectionState, - isConnected: true, - isStillReconnecting: false, - secondsUntilReconnect: () => 0, - tryReconnectNow: () => {}, - registerUserActivity: () => {}, - closeConnection: () => {}, - getSocketDebuggingInfo: () => ({ - client_id: 'fakeClientId', - transport: 'fakeTransport', - publicId: 'fakePublicId', - lastUserActivity: 0, - connectionState, - externalHeartbeat: { - currentStart: 0, - lastSuccess: 0, - lastLatency: 0, - }, - }), - } - }) - - return ( - - {children} - - ) -} - -const IdeReactProvider: FC = ({ children }) => { - const projectId = 'project-123' - const [startedFreeTrial, setStartedFreeTrial] = useState(false) - - const [ideReactContextValue] = useState(() => ({ - projectId, - eventEmitter: new IdeEventEmitter(), - startedFreeTrial, - setStartedFreeTrial, - reportError: () => {}, - projectJoined: true, - permissionsLevel: 'owner' as const, - setPermissionsLevel: () => {}, - setOutOfSync: () => {}, - })) - - const [ideContextValue] = useState(() => { - const scopeEventEmitter = new ReactScopeEventEmitter(new IdeEventEmitter()) - const unstableStore = new ReactScopeValueStore() - - window.overleaf = { - ...window.overleaf, - unstable: { - ...window.overleaf?.unstable, - store: unstableStore, - }, - } - - return { - socket, - scopeEventEmitter, - unstableStore, - } - }) - - return ( - - - {children} - - - ) -} diff --git a/services/web/frontend/stories/deprecated-browser.stories.tsx b/services/web/frontend/stories/deprecated-browser.stories.tsx deleted file mode 100644 index c353cf1b72..0000000000 --- a/services/web/frontend/stories/deprecated-browser.stories.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { Meta, StoryObj } from '@storybook/react-webpack5' -import { DeprecatedBrowser } from '@/shared/components/deprecated-browser' - -const meta: Meta = { - title: 'Project List / Deprecated Browser', - component: DeprecatedBrowser, -} - -export default meta - -type Story = StoryObj - -export const Notification: Story = {} diff --git a/services/web/frontend/stories/docs/cloud-builds.mdx b/services/web/frontend/stories/docs/cloud-builds.mdx deleted file mode 100644 index 8d0520c760..0000000000 --- a/services/web/frontend/stories/docs/cloud-builds.mdx +++ /dev/null @@ -1,23 +0,0 @@ -import { Meta } from '@storybook/addon-docs/blocks' - - - -# Cloud Builds - -Storybook builds can be used to share your development work. They can be found at [https://storybook.dev-overleaf.com/](https://storybook.dev-overleaf.com/). - -The naming scheme is predictable, i.e.: `https://storybook.dev-overleaf.com//`. - -# Triggers - -Storybook builds are automatically triggered for the main branch. For ad hoc builds of feature branches, the commits must be pushed to branches prefixed with `storybook-`. - -For example, you may temporarily push your branch to a `storybook-` branch: `git push origin my-example-feature:storybook-my-example-feature` - -You can delete it shortly after: `git push origin :storybook-my-example-feature` - -Alternatively, **if you already have a branch**, but you would like to create a Storybook, you can manually trigger the `storybook-push-trigger` on your branch via [Google Cloud Build triggers](https://console.cloud.google.com/cloud-build/triggers?project=overleaf-dev). - -Storybook builds take about 10 minutes to finish. - -**Source:** [Storybook in the developer manual](https://manual.dev-overleaf.com/development/code/storybook/) diff --git a/services/web/frontend/stories/docs/feature-flags.mdx b/services/web/frontend/stories/docs/feature-flags.mdx deleted file mode 100644 index ec0cf32d24..0000000000 --- a/services/web/frontend/stories/docs/feature-flags.mdx +++ /dev/null @@ -1,41 +0,0 @@ -import { Canvas, Controls, Meta } from '@storybook/addon-docs/blocks' -import * as FormatCurrency from './format-currency.stories' - - - -# Feature flags - -You can wrap your story with the `withSplitTests` utility to add split test variants to your Storybook stories, so you can toggle feature flags directly in the Storybook UI. See [withSplitTests on GitHub](https://github.com/overleaf/internal/blob/main/services/web/.storybook/utils/with-split-tests.tsx). - -1. Define your split test argTypes: - Add your split test configurations to the `splitTestsArgTypes` object. - -```js -export const splitTestsArgTypes = { - 'local-ccy-format': { - description: 'Use local currency formatting', - control: { type: 'radio' }, - options: ['default', 'enabled'], - }, -} -``` - -2. Wrap your story with `withSplitTests`. - -Import `withSplitTests` and `Meta` from '@storybook/react-webpack5' in your stories. - -```js -export default { - ...config, - ...withSplitTests(config, ['local-ccy-format']), -} -``` - -## Example - -Resulting stories will have added controls to define the variants of the split tests. - -
- - -
diff --git a/services/web/frontend/stories/docs/format-currency.stories.tsx b/services/web/frontend/stories/docs/format-currency.stories.tsx deleted file mode 100644 index da0d0de7ca..0000000000 --- a/services/web/frontend/stories/docs/format-currency.stories.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import React from 'react' -import { useSplitTest } from '@/shared/context/split-test-context' -import { withSplitTests } from '../../../.storybook/utils/with-split-tests' - -const FormatCurrency = () => { - const { variant } = useSplitTest('local-ccy-format') - const formatCurrency = (amount: number, narrowSymbol: boolean) => { - return variant === 'enabled' - ? new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - currencyDisplay: narrowSymbol ? 'narrowSymbol' : 'name', - }).format(amount) - : `USD ${amount}` - } - return ( -
- Variant: {JSON.stringify(variant)} -
- Long: {formatCurrency(1234.56, false)} -
- Short: {formatCurrency(1234.56, true)} -
- ) -} - -const config = { - title: 'Storybook Guideline / Feature Flags', // Must match MDX title exactly - component: FormatCurrency, -} - -export default { - ...config, - ...withSplitTests(config, ['local-ccy-format'], { - 'local-ccy-format': { - description: 'Use local currency formatting', - control: { type: 'radio' as const }, - options: ['default', 'enabled'], - }, - }), - tags: ['!dev'], // hides in the sidebar -} - -export const Story = {} diff --git a/services/web/frontend/stories/docs/foundations.mdx b/services/web/frontend/stories/docs/foundations.mdx deleted file mode 100644 index d764e65adf..0000000000 --- a/services/web/frontend/stories/docs/foundations.mdx +++ /dev/null @@ -1,256 +0,0 @@ -import { - Meta, - ColorItem, - ColorPalette, - Title, - Typeset, -} from '@storybook/addon-docs/blocks' -import colors from '../../stylesheets/foundations/tokens/colors.json' -import typography from '../../stylesheets/foundations/tokens/typography.json' -import borderRadius from '../../stylesheets/foundations/tokens/borderRadius.json' -import spacing from '../../stylesheets/foundations/tokens/spacing.json' - - - -# Foundations - -Foundations in UX design are the basic rules and core elements, like colors, fonts, and spacing, that ensure consistency across the design. - -

- These palettes are generated from our token files. The tokens were exported - from Figma and split into separate files to categorise:{' '} - - Token files on GitHub - -

- -**Table of Contents** - -- [Primitive colors](#primitive-colors) -- [Background colors](#background-colors) -- [Content colors](#content-colors) -- [Border colors](#border-colors) -- [Link colors](#link-colors) -- [Special colors](#special-colors) -- [Font weight](#font-weight) -- [Font size](#font-size) -- [Border radius](#border-radius) -- [Spacing](#spacing) - -# Colors - -
- -## Primitive colors - - - {Object.entries(colors.PrimitiveColor).map(([name, color]) => ( - - ))} - - -## Background colors - -Colors used for page and component backgrounds. - - - {Object.entries(colors) - .filter(([name]) => name.startsWith('bg-')) - .map(([name, colors]) => ( - - ))} - - -## Content colors - -Colors used for text and icons. - - - {Object.entries(colors) - .filter(([name]) => name.startsWith('content-')) - .map(([name, colors]) => ( - - ))} - - -## Border colors - -Colors used for borders and dividers. - - - {Object.entries(colors) - .filter(([name]) => name.startsWith('border-')) - .map(([name, colors]) => ( - - ))} - - -## Link colors - -Colors used for hyperlink states. - - - {Object.entries(colors) - .filter(([name]) => name.startsWith('link-')) - .map(([name, colors]) => ( - - ))} - - -## Special colors - -Colors used for specific UI elements like color pickers. - - - {Object.entries(colors) - .filter(([name]) => name.startsWith('special-')) - .map(([name, colors]) => ( - - ))} - - -# Typography - -
- -## Font weight - -
- -{Object.entries(typography.Fontweight).map(([name, typography]) => ( - -
- {name} - -
-))} - -## Font size - -
- -{Object.entries(typography['Fontsize']).map(([name, typography]) => { -const mixins = typography.$mixin ? [].concat(typography.$mixin) : []; - -return ( - -
- -{name} -{mixins.map(mixin => ( - / @mixin {mixin} -))} - - -
-); -})} - -## Border radius - -
- - - - - - - - - - - {Object.entries(borderRadius['BorderRadius']).map( - ([name, borderRadius]) => ( - - - - - - ) - )} - -
Variable nameValueExample
- {name} - {borderRadius.$value}px -
-
- -## Spacing - -
- - - - - - - - - - - {Object.entries(spacing.Spacing) - .filter(([, spacing]) => spacing.$value > 0) - .map(([name, spacing]) => ( - - - - - - ))} - -
Variable NameValueExample
- {name} - {spacing.$value}px -
-
diff --git a/services/web/frontend/stories/editor-switch.stories.jsx b/services/web/frontend/stories/editor-switch.stories.jsx deleted file mode 100644 index 2db4255768..0000000000 --- a/services/web/frontend/stories/editor-switch.stories.jsx +++ /dev/null @@ -1,12 +0,0 @@ -import EditorSwitch from '../js/features/source-editor/components/editor-switch' -import { ScopeDecorator } from './decorators/scope' - -export default { - title: 'Editor / Switch', - component: EditorSwitch, - decorators: [ScopeDecorator], -} - -export const Switcher = () => { - return -} diff --git a/services/web/frontend/stories/editor/pdf-compile-button.stories.tsx b/services/web/frontend/stories/editor/pdf-compile-button.stories.tsx deleted file mode 100644 index d83aa2f3a6..0000000000 --- a/services/web/frontend/stories/editor/pdf-compile-button.stories.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { FC } from 'react' -import type { Meta } from '@storybook/react-webpack5' -import PdfCompileButton from '@/features/pdf-preview/components/pdf-compile-button' -import { ScopeDecorator } from '../decorators/scope' -import { CompileContext } from '@/shared/context/local-compile-context' -import { DetachCompileContext } from '@/shared/context/detach-compile-context' - -export const CompileButton: FC = (props: CompileContext) => ( - -
-
-
- -
-
-
-
-) - -const args: Partial = { - autoCompile: false, - compiling: false, - draft: false, - hasChanges: false, - stopOnFirstError: false, - stopOnValidationError: false, - animateCompileDropdownArrow: false, -} - -const meta: Meta = { - title: 'Editor / Toolbar / Compile Button', - component: CompileButton, - // @ts-ignore - decorators: [ScopeDecorator], - argTypes: { - startCompile: { action: 'startCompile' }, - setAutoCompile: { action: 'setAutoCompile' }, - setCompiling: { action: 'setCompiling' }, - setDraft: { action: 'setDraft' }, - setStopOnFirstError: { action: 'setStopOnFirstError' }, - setError: { action: 'setError' }, - setHasLintingError: { action: 'setHasLintingError' }, - setPosition: { action: 'setPosition' }, - setStopOnValidationError: { action: 'setStopOnValidationError' }, - recompileFromScratch: { action: 'recompileFromScratch' }, - stopCompile: { action: 'stopCompile' }, - setAnimateCompileDropdownArrow: { - action: 'setAnimateCompileDropdownArrow', - }, - }, - args, -} - -export default meta diff --git a/services/web/frontend/stories/editor/synctex-toasts.stories.tsx b/services/web/frontend/stories/editor/synctex-toasts.stories.tsx deleted file mode 100644 index f934e8c2bc..0000000000 --- a/services/web/frontend/stories/editor/synctex-toasts.stories.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { Meta, StoryObj } from '@storybook/react-webpack5' -import { OLToast } from '@/shared/components/ol/ol-toast' -import { SynctexFileErrorToast } from '@/features/pdf-preview/components/synctex-toasts' - -const meta = { - title: 'Editor/ Synctex File Error Toast', - component: SynctexFileErrorToast, - decorators: [ - Story => ( -
- } /> -
- ), - ], -} satisfies Meta - -export default meta -type Story = StoryObj - -export const WithoutFile = { - args: { data: {} }, -} satisfies Story - -export const WithFile = { - args: { data: { filePath: 'references.bbl' } }, -} satisfies Story diff --git a/services/web/frontend/stories/file-tree.stories.jsx b/services/web/frontend/stories/file-tree.stories.jsx deleted file mode 100644 index 51621cb094..0000000000 --- a/services/web/frontend/stories/file-tree.stories.jsx +++ /dev/null @@ -1,180 +0,0 @@ -import { rootFolderBase } from './fixtures/file-tree-base' -import { rootFolderLimit } from './fixtures/file-tree-limit' -import FileTreeRoot from '../js/features/file-tree/components/file-tree-root' -import FileTreeError from '../js/features/file-tree/components/file-tree-error' -import useFetchMock from './hooks/use-fetch-mock' -import { ScopeDecorator } from './decorators/scope' -import { useScope } from './hooks/use-scope' -import { useIdeContext } from '@/shared/context/ide-context' - -const MOCK_DELAY = 2000 - -const DEFAULT_PROJECT = { - _id: '123abc', - name: 'Some Project', - rootDocId: '5e74f1a7ce17ae0041dfd056', - rootFolder: rootFolderBase, -} - -function defaultSetupMocks(fetchMock, socket) { - fetchMock - .post( - /\/project\/\w+\/(file|doc|folder)\/\w+\/rename/, - (path, req) => { - const body = JSON.parse(req.body) - const entityId = path.match(/([^/]+)\/rename$/)[1] - socket.emitToClient('reciveEntityRename', entityId, body.name) - return 204 - }, - - { - delay: MOCK_DELAY, - } - ) - .post( - /\/project\/\w+\/folder/, - (_path, req) => { - const body = JSON.parse(req.body) - const newFolder = { - folders: [], - fileRefs: [], - docs: [], - _id: Math.random().toString(16).replace(/0\./, 'random-test-id-'), - name: body.name, - } - socket.emitToClient('reciveNewFolder', body.parent_folder_id, newFolder) - return newFolder - }, - { - delay: MOCK_DELAY, - } - ) - .delete( - /\/project\/\w+\/(file|doc|folder)\/\w+/, - path => { - const entityId = path.match(/[^/]+$/)[0] - socket.emitToClient('removeEntity', entityId) - return 204 - }, - { - delay: MOCK_DELAY, - } - ) - .post(/\/project\/\w+\/(file|doc|folder)\/\w+\/move/, (path, req) => { - const body = JSON.parse(req.body) - const entityId = path.match(/([^/]+)\/move/)[1] - socket.emitToClient('reciveEntityMove', entityId, body.folder_id) - return 204 - }) -} - -export const FullTree = args => { - const { socket } = useIdeContext() - useFetchMock(fetchMock => defaultSetupMocks(fetchMock, socket)) - - useScope({ - project: DEFAULT_PROJECT, - permissionsLevel: 'owner', - }) - - return -} - -export const ReadOnly = args => { - useScope({ - project: DEFAULT_PROJECT, - permissionsLevel: 'readOnly', - }) - - return -} - -export const Disconnected = args => { - useScope({ - project: DEFAULT_PROJECT, - permissionsLevel: 'owner', - }) - - return -} -Disconnected.args = { isConnected: false } - -export const NetworkErrors = args => { - useFetchMock(fetchMock => { - fetchMock - .post(/\/project\/\w+\/folder/, 500, { - delay: MOCK_DELAY, - }) - .post(/\/project\/\w+\/(file|doc|folder)\/\w+\/rename/, 500, { - delay: MOCK_DELAY, - }) - .post(/\/project\/\w+\/(file|doc|folder)\/\w+\/move/, 500, { - delay: MOCK_DELAY, - }) - .delete(/\/project\/\w+\/(file|doc|folder)\/\w+/, 500, { - delay: MOCK_DELAY, - }) - }) - - useScope({ - project: DEFAULT_PROJECT, - permissionsLevel: 'owner', - }) - - return -} - -export const FallbackError = args => { - useScope({ - project: DEFAULT_PROJECT, - }) - - return -} - -export const FilesLimit = args => { - const { socket } = useIdeContext() - useFetchMock(fetchMock => defaultSetupMocks(fetchMock, socket)) - - useScope({ - project: { - ...DEFAULT_PROJECT, - rootFolder: rootFolderLimit, - }, - permissionsLevel: 'owner', - }) - - return -} - -export default { - title: 'Editor / File Tree', - component: FileTreeRoot, - args: { - setStartedFreeTrial: () => { - console.log('started free trial') - }, - refProviders: {}, - setRefProviderEnabled: provider => { - console.log(`ref provider ${provider} enabled`) - }, - isConnected: true, - }, - argTypes: { - onInit: { action: 'onInit' }, - onSelect: { action: 'onSelect' }, - }, - decorators: [ - ScopeDecorator, - Story => ( - <> - -
-
- -
-
- - ), - ], -} diff --git a/services/web/frontend/stories/file-view/file-view.stories.jsx b/services/web/frontend/stories/file-view/file-view.stories.jsx deleted file mode 100644 index 80c4363c9d..0000000000 --- a/services/web/frontend/stories/file-view/file-view.stories.jsx +++ /dev/null @@ -1,204 +0,0 @@ -import FileView from '../../js/features/file-view/components/file-view' -import useFetchMock from '../hooks/use-fetch-mock' -import { ScopeDecorator } from '../decorators/scope' - -const bodies = { - latex: `\\documentclass{article} -\\begin{document} -First document. This is a simple example, with no -extra parameters or packages included. -\\end{document}`, - bibtex: `@book{latexcompanion, - author = "Michel Goossens and Frank Mittelbach and Alexander Samarin", - title = "The \\LaTeX\\ Companion", - year = "1993", - publisher = "Addison-Wesley", - address = "Reading, Massachusetts" -}`, - text: `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.`, -} - -const setupFetchMock = fetchMock => { - return fetchMock - .head('express:/project/:project_id/blob/:hash', { - status: 201, - headers: { 'Content-Length': 10000 }, - }) - .post('express:/project/:project_id/linked_file/:file_id/refresh', { - status: 204, - }) - .post('express:/project/:project_id/references/indexAll', { - status: 204, - }) -} - -const fileData = { - id: 'file-id', - name: 'file.tex', - hash: 'c0ffee', - created: new Date().toISOString(), -} - -export const FileFromUrl = args => { - useFetchMock(fetchMock => - setupFetchMock(fetchMock).get('express:/project/:project_id/blob/:hash', { - body: bodies.latex, - }) - ) - - return -} -FileFromUrl.args = { - file: { - ...fileData, - linkedFileData: { - url: 'https://example.com/source-file.tex', - provider: 'url', - }, - }, -} - -export const FileFromProjectWithLinkableProjectId = args => { - useFetchMock(fetchMock => - setupFetchMock(fetchMock).get('express:/project/:project_id/blob/:hash', { - body: bodies.latex, - }) - ) - - return -} -FileFromProjectWithLinkableProjectId.args = { - file: { - ...fileData, - linkedFileData: { - source_project_id: 'source-project-id', - source_entity_path: '/source-file.tex', - provider: 'project_file', - }, - }, -} - -export const FileFromProjectWithoutLinkableProjectId = args => { - useFetchMock(fetchMock => - setupFetchMock(fetchMock).get('express:/project/:project_id/blob/:hash', { - body: bodies.latex, - }) - ) - - return -} -FileFromProjectWithoutLinkableProjectId.args = { - file: { - ...fileData, - linkedFileData: { - v1_source_doc_id: 'v1-source-id', - source_entity_path: '/source-file.tex', - provider: 'project_file', - }, - }, -} - -export const FileFromProjectOutputWithLinkableProject = args => { - useFetchMock(fetchMock => - setupFetchMock(fetchMock).get('express:/project/:project_id/blob/:hash', { - body: bodies.latex, - }) - ) - - return -} -FileFromProjectOutputWithLinkableProject.args = { - file: { - ...fileData, - linkedFileData: { - source_project_id: 'source_project_id', - source_output_file_path: '/source-file.tex', - provider: 'project_output_file', - }, - }, -} - -export const FileFromProjectOutputWithoutLinkableProjectId = args => { - useFetchMock(fetchMock => - setupFetchMock(fetchMock).get('express:/project/:project_id/blob/:hash', { - body: bodies.latex, - }) - ) - - return -} -FileFromProjectOutputWithoutLinkableProjectId.args = { - file: { - ...fileData, - linkedFileData: { - v1_source_doc_id: 'v1-source-id', - source_output_file_path: '/source-file.tex', - provider: 'project_output_file', - }, - }, -} - -export const ImageFile = args => { - useFetchMock(setupFetchMock) // NOTE: can't mock img src request - - return -} -ImageFile.storyName = 'Image File (Error)' -ImageFile.args = { - file: { - ...fileData, - id: '60097ca20454610027c442a8', - name: 'file.jpg', - linkedFileData: { - source_project_id: 'source_project_id', - source_entity_path: '/source-file.jpg', - provider: 'project_file', - }, - }, -} - -export const TextFile = args => { - useFetchMock(fetchMock => - setupFetchMock(fetchMock).get('express:/project/:project_id/blob/:hash', { - body: bodies.text, - }) - ) - return -} -TextFile.args = { - file: { - ...fileData, - linkedFileData: { - source_project_id: 'source-project-id', - source_entity_path: '/source-file.txt', - provider: 'project_file', - }, - name: 'file.txt', - }, -} - -export const UploadedFile = args => { - useFetchMock(fetchMock => - setupFetchMock(fetchMock).head('express:/project/:project_id/blob/:hash', { - status: 500, - }) - ) - return -} -UploadedFile.storyName = 'Uploaded File (Error)' -UploadedFile.args = { - file: { - ...fileData, - linkedFileData: null, - name: 'file.jpg', - }, -} - -export default { - title: 'Editor / FileView', - component: FileView, - argTypes: { - storeReferencesKeys: { action: 'store references keys' }, - }, - decorators: [ScopeDecorator], -} diff --git a/services/web/frontend/stories/fixtures/chat-messages.js b/services/web/frontend/stories/fixtures/chat-messages.js deleted file mode 100644 index 78b91fa8f0..0000000000 --- a/services/web/frontend/stories/fixtures/chat-messages.js +++ /dev/null @@ -1,34 +0,0 @@ -const ONE_MINUTE = 60 * 1000 - -const user = { - id: 'fake_user', - first_name: 'mortimer', - email: 'fake@example.com', -} - -const user2 = { - id: 'another_fake_user', - first_name: 'leopold', - email: 'another_fake@example.com', -} - -let nextMessageId = 1 - -export function generateMessages(count) { - const messages = [] - let timestamp = new Date().getTime() // newest message goes first - for (let i = 0; i <= count; i++) { - const author = Math.random() > 0.5 ? user : user2 - // modify the timestamp so the previous message has 70% chances to be within 5 minutes from - // the current one, for grouping purposes - timestamp -= (4.3 + Math.random()) * ONE_MINUTE - - messages.push({ - id: '' + nextMessageId++, - content: `message #${i}`, - user: author, - timestamp, - }) - } - return messages -} diff --git a/services/web/frontend/stories/fixtures/compile.js b/services/web/frontend/stories/fixtures/compile.js deleted file mode 100644 index b0303e1f81..0000000000 --- a/services/web/frontend/stories/fixtures/compile.js +++ /dev/null @@ -1,220 +0,0 @@ -import examplePdf from './storybook-example.pdf' -import { cloneDeep } from 'lodash' - -export const dispatchDocChanged = () => { - window.dispatchEvent( - new CustomEvent('doc:changed', { detail: { doc_id: 'foo' } }) - ) -} - -export const outputFiles = [ - { - path: 'output.pdf', - build: '1234-5678', - url: '/build/output.pdf', - type: 'pdf', - }, - { - path: 'output.bbl', - build: '1234-5678', - url: '/build/output.bbl', - type: 'bbl', - }, - { - path: 'output.bib', - build: '1234-5678', - url: '/build/output.bib', - type: 'bib', - }, - { - path: 'example.txt', - build: '1234-5678', - url: '/build/example.txt', - type: 'txt', - }, - { - path: 'output.log', - build: '1234-5678', - url: '/build/output.log', - type: 'log', - }, - { - path: 'output.blg', - build: '1234-5678', - url: '/build/output.blg', - type: 'blg', - }, -] - -export const mockCompile = (fetchMock, delay = 1000) => - fetchMock.post( - 'express:/project/:projectId/compile', - { - body: { - status: 'success', - clsiServerId: 'foo', - compileGroup: 'priority', - pdfDownloadDomain: '', - outputFiles: cloneDeep(outputFiles), - }, - }, - { delay } - ) - -export const mockCompileError = (fetchMock, status = 'success', delay = 1000) => - fetchMock.post( - 'express:/project/:projectId/compile', - { - body: { - status, - clsiServerId: 'foo', - compileGroup: 'priority', - }, - }, - { delay, overwriteRoutes: true } - ) - -export const mockCompileValidationIssues = ( - fetchMock, - validationProblems, - delay = 1000 -) => - fetchMock.post( - 'express:/project/:projectId/compile', - () => { - return { - body: { - status: 'validation-problems', - validationProblems, - clsiServerId: 'foo', - compileGroup: 'priority', - }, - } - }, - { delay } - ) - -export const mockClearCache = fetchMock => - fetchMock.delete('express:/project/:projectId/output', 204, { - delay: 1000, - }) - -export const mockBuildFile = fetchMock => - fetchMock.get('express:/build/:file', ({ url }) => { - const { pathname } = new URL(url, 'https://example.com') - - switch (pathname) { - case '/build/output.blg': - return 'This is BibTeX, Version 4.0' // FIXME - - case '/build/output.log': - return ` -The LaTeX compiler output - * With a lot of details - -Wrapped in an HTML
 element with
-      preformatted text which is to be presented exactly
-            as written in the HTML file
-
-                                              (whitespace included™)
-
-The text is typically rendered using a non-proportional ("monospace") font.
-
-LaTeX Font Info:    External font \`cmex10' loaded for size
-(Font)              <7> on input line 18.
-LaTeX Font Info:    External font \`cmex10' loaded for size
-(Font)              <5> on input line 18.
-! Undefined control sequence.
- \\Zlpha
-
- main.tex, line 23
-
-`
-
-      case '/build/output.pdf':
-        return new Promise(resolve => {
-          const xhr = new XMLHttpRequest()
-          xhr.addEventListener('load', () => {
-            resolve({
-              status: 200,
-              headers: {
-                'Content-Length': xhr.getResponseHeader('Content-Length'),
-                'Content-Type': xhr.getResponseHeader('Content-Type'),
-              },
-              body: xhr.response,
-            })
-          })
-          xhr.open('GET', examplePdf)
-          xhr.responseType = 'arraybuffer'
-          xhr.send()
-        })
-
-      default:
-        console.log(pathname)
-        return 404
-    }
-  })
-
-const mockHighlights = [
-  {
-    page: 1,
-    h: 85.03936,
-    v: 509.999878,
-    width: 441.921265,
-    height: 8.855677,
-  },
-  {
-    page: 1,
-    h: 85.03936,
-    v: 486.089539,
-    width: 441.921265,
-    height: 8.855677,
-  },
-  {
-    page: 1,
-    h: 85.03936,
-    v: 498.044708,
-    width: 441.921265,
-    height: 8.855677,
-  },
-  {
-    page: 1,
-    h: 85.03936,
-    v: 521.955078,
-    width: 441.921265,
-    height: 8.855677,
-  },
-]
-
-export const mockEventTracking = fetchMock =>
-  fetchMock.get('express:/event/:event', 204)
-
-export const mockValidPdf = fetchMock =>
-  fetchMock.get('express:/build/output.pdf', () => {
-    return new Promise(resolve => {
-      const xhr = new XMLHttpRequest()
-      xhr.addEventListener('load', () => {
-        resolve({
-          status: 200,
-          headers: {
-            'Content-Length': xhr.getResponseHeader('Content-Length'),
-            'Content-Type': xhr.getResponseHeader('Content-Type'),
-            'Accept-Ranges': 'bytes',
-          },
-          body: xhr.response,
-        })
-      })
-      xhr.open('GET', examplePdf)
-      xhr.responseType = 'arraybuffer'
-      xhr.send()
-    })
-  })
-
-export const mockSynctex = fetchMock =>
-  fetchMock
-    .get('express:/project/:projectId/sync/code', () => {
-      return { pdf: cloneDeep(mockHighlights) }
-    })
-    .get('express:/project/:projectId/sync/pdf', () => {
-      return { code: [{ file: 'main.tex', line: 100 }] }
-    })
diff --git a/services/web/frontend/stories/fixtures/contacts.js b/services/web/frontend/stories/fixtures/contacts.js
deleted file mode 100644
index 10c0348ed0..0000000000
--- a/services/web/frontend/stories/fixtures/contacts.js
+++ /dev/null
@@ -1,41 +0,0 @@
-export const contacts = [
-  // user with edited name
-  {
-    type: 'user',
-    email: 'test-user@example.com',
-    first_name: 'Test',
-    last_name: 'User',
-    name: 'Test User',
-  },
-  // user with default name (email prefix)
-  {
-    type: 'user',
-    email: 'test@example.com',
-    first_name: 'test',
-  },
-  // no last name
-  {
-    type: 'user',
-    first_name: 'Eratosthenes',
-    email: 'eratosthenes@example.com',
-  },
-  // more users
-  {
-    type: 'user',
-    first_name: 'Claudius',
-    last_name: 'Ptolemy',
-    email: 'ptolemy@example.com',
-  },
-  {
-    type: 'user',
-    first_name: 'Abd al-Rahman',
-    last_name: 'Al-Sufi',
-    email: 'al-sufi@example.com',
-  },
-  {
-    type: 'user',
-    first_name: 'Nicolaus',
-    last_name: 'Copernicus',
-    email: 'copernicus@example.com',
-  },
-]
diff --git a/services/web/frontend/stories/fixtures/document.ts b/services/web/frontend/stories/fixtures/document.ts
deleted file mode 100644
index ad2995a4dc..0000000000
--- a/services/web/frontend/stories/fixtures/document.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-export function mockDocument(text: string) {
-  return {
-    doc_id: 'story-doc',
-    getSnapshot: () => text,
-    hasBufferedOps: () => false,
-  }
-}
-
-export const document = {
-  tex: `\\documentclass{article}
-
-% Language setting
-% Replace \`english' with e.g. \`spanish' to change the document language
-\\usepackage[english]{babel}
-
-% Set page size and margins
-% Replace \`letterpaper' with\`a4paper' for UK/EU standard size
-\\usepackage[letterpaper,top=2cm,bottom=2cm,left=3cm,right=3cm,marginparwidth=1.75cm]{geometry}
-
-% Useful packages
-\\usepackage{amsmath}
-\\usepackage{graphicx}
-\\usepackage[colorlinks=true, allcolors=blue]{hyperref}
-
-\\title{Your Paper}
-\\author{You}
-
-\\begin{document}
-\\maketitle
-
-\\begin{abstract}
-Your abstract.
-\\end{abstract}
-
-\\section{Introduction}
-
-Your introduction goes here! Simply start writing your document and use the Recompile button to view the updated PDF preview. Examples of commonly used commands and features are listed below, to help you get started.
-
-Once you're familiar with the editor, you can find various project setting in the Overleaf menu, accessed via the button in the very top left of the editor. To view tutorials, user guides, and further documentation, please visit our \\href{https://www.overleaf.com/learn}{help library}, or head to our plans page to \\href{https://www.overleaf.com/user/subscription/plans}{choose your plan}.`,
-}
diff --git a/services/web/frontend/stories/fixtures/file-tree-base.js b/services/web/frontend/stories/fixtures/file-tree-base.js
deleted file mode 100644
index e3237030c5..0000000000
--- a/services/web/frontend/stories/fixtures/file-tree-base.js
+++ /dev/null
@@ -1,44 +0,0 @@
-export const rootFolderBase = [
-  {
-    _id: '5e74f1a7ce17ae0041dfd054',
-    name: 'rootFolder',
-    folders: [
-      {
-        _id: '5f638e58b652df0026c5c8f5',
-        name: 'a folder',
-        folders: [
-          {
-            _id: '5f956f62700e19000177daa0',
-            name: 'sub folder',
-            folders: [],
-            fileRefs: [],
-            docs: [],
-          },
-        ],
-        fileRefs: [
-          { _id: '5cffb9d93da45d3995d05362', name: 'file-in-a-folder.pdf' },
-        ],
-        docs: [
-          { _id: '5f46786322d556004e72a555', name: 'doc-in-a-folder.tex' },
-        ],
-      },
-      {
-        _id: '5f638e68b652df0026c5c8f6',
-        name: 'another folder',
-        folders: [],
-        fileRefs: [],
-        docs: [],
-      },
-    ],
-    fileRefs: [{ _id: '5f11c78e0924770027412a67', name: 'univers.jpg' }],
-    docs: [
-      { _id: '5e74f1a7ce17ae0041dfd056', name: 'main.tex' },
-      { _id: '5f46789522d556004e72a556', name: 'perso.bib' },
-      {
-        _id: '5da532e29019e800015321c6',
-        name: 'zotero.bib',
-        linkedFileData: { provider: 'zotero' },
-      },
-    ],
-  },
-]
diff --git a/services/web/frontend/stories/fixtures/file-tree-limit.js b/services/web/frontend/stories/fixtures/file-tree-limit.js
deleted file mode 100644
index b403405b65..0000000000
--- a/services/web/frontend/stories/fixtures/file-tree-limit.js
+++ /dev/null
@@ -1,52 +0,0 @@
-const FILE_PER_FOLDER = 2
-const DOC_PER_FOLDER = 3
-const FOLDER_PER_FOLDER = 2
-const MAX_DEPTH = 7
-
-function fakeId() {
-  return Math.random().toString(16).replace(/0\./, 'random-test-id-')
-}
-
-function makeFileRefs(path) {
-  const fileRefs = []
-
-  for (let index = 0; index < FILE_PER_FOLDER; index++) {
-    fileRefs.push({ _id: fakeId(), name: `${path}-file-${index}.jpg` })
-  }
-  return fileRefs
-}
-
-function makeDocs(path) {
-  const docs = []
-
-  for (let index = 0; index < DOC_PER_FOLDER; index++) {
-    docs.push({ _id: fakeId(), name: `${path}-doc-${index}.tex` })
-  }
-  return docs
-}
-
-function makeFolders(path, depth = 0) {
-  const folders = []
-
-  for (let index = 0; index < FOLDER_PER_FOLDER; index++) {
-    const folderPath = `${path}-folder-${index}`
-    folders.push({
-      _id: fakeId(),
-      name: folderPath,
-      folders: depth < MAX_DEPTH ? makeFolders(folderPath, depth + 1) : [],
-      fileRefs: makeFileRefs(folderPath),
-      docs: makeDocs(folderPath),
-    })
-  }
-  return folders
-}
-
-export const rootFolderLimit = [
-  {
-    _id: fakeId(),
-    name: 'rootFolder',
-    folders: makeFolders('root'),
-    fileRefs: makeFileRefs('root'),
-    docs: makeDocs('root'),
-  },
-]
diff --git a/services/web/frontend/stories/fixtures/project.ts b/services/web/frontend/stories/fixtures/project.ts
deleted file mode 100644
index 9cc54c4fc4..0000000000
--- a/services/web/frontend/stories/fixtures/project.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import { Project } from '../../../types/project'
-
-export const project: Project = {
-  _id: '63e21c07946dd8c76505f85a',
-  name: 'A Project',
-  features: {
-    collaborators: -1, // unlimited
-  },
-  publicAccesLevel: 'private',
-  tokens: {
-    readOnly: 'ro-token',
-    readAndWrite: 'rw-token',
-  },
-  owner: {
-    _id: 'project-owner',
-    email: 'stories@overleaf.com',
-  },
-  members: [
-    {
-      _id: 'viewer-member',
-      type: 'user',
-      privileges: 'readOnly',
-      name: 'Viewer User',
-      email: 'viewer@example.com',
-    },
-    {
-      _id: 'author-member',
-      type: 'user',
-      privileges: 'readAndWrite',
-      name: 'Author User',
-      email: 'author@example.com',
-    },
-  ],
-  invites: [
-    {
-      _id: 'test-invite-1',
-      privileges: 'readOnly',
-      name: 'Invited Viewer',
-      email: 'invited-viewer@example.com',
-    },
-    {
-      _id: 'test-invite-2',
-      privileges: 'readAndWrite',
-      name: 'Invited Author',
-      email: 'invited-author@example.com',
-    },
-  ],
-}
diff --git a/services/web/frontend/stories/fixtures/storybook-example.pdf b/services/web/frontend/stories/fixtures/storybook-example.pdf
deleted file mode 100644
index 4c2cc8512d2f0ae5ba749004a0fd87cefe9d9645..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001

literal 248910
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�yTt%870u zr^H;fxt+c#`PO0Op%_Hw{nBeznZKq)xrf;BNyU#ZgT`PR^}fIS6I01G*E_N5$iEB-_%boGOUM6iwV+@M?(3` z#8AKmk+jF0Se0ji3XmR{3ht}ENH2y3CNB)7nT&8s;(FFU?@K707nZf9AP1=#o0&-s zG`y{bRu$sJj?-R=o`#lD|3WkesSX|AbJK-A^|= zVDw}f6w8axR%J&MaYeC_?a%uA@pu>i*crc|7f$YHO-Z+mX!-yd1!9;OzSYe*6N(T7 zs_M4ewJApgl&uG;tSeVxzxWjlauhCFdd6+6@FRx*xRL7asF#_hU)eN&-Ld<#gs>Uc z9a=??W4L`H zpwGpZ>tSKbY^8~}bcbGYTU^Zc9g9AvME?Q28!WN8QV)pb4LxXDd2c-As@bXzli|h- zL5bNoV?bCHtXG)8}sg0gW$t|p^vL2Quth2~v8gXHSTO!=I2uM^oH=#E|s4KhGmskzs~xGH)5n$Wa!9>3pnvZU_D={ z_L%^)2+{@3ZO&|26U_wcT^vAD+TibEk5mH0*ZQj5^K%Kt6h#( zfAQL&rY6xGh6l|oNnAB{-GF)11wPplO&tI^@;n*NB~cqcJ;e?VGvas&>YQ2Xx*5rX zT$R?ciKVCpisdu+E6+L4PLl-_!-`)5Q^P$B{56wGoq|p+#(E7FDvtwSda3NZhb>>8wfWkr;pya0ZG19;y4*hbVP!p zoOIm$aaoXIMq8;wo|NVOceQ8vSMpilPL7v`1MQ$G;NU9=!Ts4KrpPm+P!G&bB#<#P zCpS6W;yc0<=C@SD-p3Qd8hLKLZrvtkMF8TO3gV|(FY~KvuZn{T7I?waZ+$Y!Td$*{ z=~L?9GgEkm>b$KM?l+Pm1O&r^{F7;AD32brTE%zf-7mwHJu5l;S?qGhwQ~oe@sNK4 z6_}`}WVfM-%R&|5nvJFNNo5@`JsLg`mZXM^SYjpzLSoPN<%lej4}B>f59*i3J(`zN znHYJ}iL-IpRRGbZEE~gO1UOfTB6!R}S2Sff|3|-Yd|yX+@lW*=vyzTA<(L@UcvV%@ zWut=7Z_;hBKmYa0-7AunNKX&&)-D(L}I?+NGh3icZ|}aQMQv zswbs0mbv8wQaiZtwh^d#Vp}j|QXl|z{uHdbZn&D0Ac;NIvj|2n8Kkzn_15bwb5B+$ zv@=t0NoohRwt-%6=RbHuJ`=xd5^oa}Y9=I1UNCr}2Xc1ii6NTqtcmG>wgk0(F!#y% z=^46pLP_eKA1OGsliNX-y*iy7F3}xNXrW>EN}LHJ{zw^xzA|^ zzA5V9=H=~m)e7#zKhO8YW2k)=#xzcSfRob3Xie>COq!+tyxfo3>cqvCoOvZvi}|`Y zZN4Ic5*A)9s@V|HcjZ0Ysvg$RhlT!Ga$0tF2vjrFvo5?!0u9jJME3s`MqBjE z(WfruM^KuK^;Rn10TNFyxj*CwriUSKDfL>-8HHxi@@l@O4Lao#s8_kZZ`~o⪚~8 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�$*r^*%$D@KkRUtQ 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
-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 diff --git a/services/web/frontend/stories/history/document-diff-viewer.stories.tsx b/services/web/frontend/stories/history/document-diff-viewer.stories.tsx deleted file mode 100644 index fa8014a157..0000000000 --- a/services/web/frontend/stories/history/document-diff-viewer.stories.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import { ScopeDecorator } from '../decorators/scope' -import DocumentDiffViewer from '../../js/features/history/components/diff-view/document-diff-viewer' -import React from 'react' -import { Highlight } from '../../js/features/history/services/types/doc' - -const highlights: Highlight[] = [ - { - type: 'addition', - range: { from: 3, to: 10 }, - hue: 200, - label: 'Added by Wombat on Monday', - }, - { - type: 'deletion', - range: { from: 15, to: 25 }, - hue: 62, - label: 'Deleted by Duck on Monday', - }, - { - type: 'addition', - range: { from: 100, to: 400 }, - hue: 200, - label: 'Added by Wombat on Friday', - }, - { - type: 'deletion', - range: { from: 564, to: 565 }, - hue: 200, - label: 'Deleted by Wombat on Friday', - }, - { - type: 'addition', - range: { from: 1770, to: 1780 }, - hue: 200, - label: 'Added by Wombat on Tuesday', - }, -] - -const doc = `\\documentclass{article} - -% Language setting -% Replace \`english' with e.g. \`spanish' to change the document language -\\usepackage[english]{babel} - -% Set page size and margins -% Replace \`letterpaper' with \`a4paper' for UK/EU standard size -\\usepackage[letterpaper,top=2cm,bottom=2cm,left=3cm,right=3cm,marginparwidth=1.75cm]{geometry} - -% Useful packages -\\usepackage{amsmath} -\\usepackage{graphicx} -\\usepackage[colorlinks=true, allcolors=blue]{hyperref} - -\\title{Your Paper} -\\author{You} - -\\begin{document} -\\maketitle - -\\begin{abstract} -Your abstract. -\\end{abstract} - -\\section{Introduction} - -Your introduction goes here! Simply start writing your document and use the Recompile button to view the updated PDF preview. Examples of commonly used commands and features are listed below, to help you get started. - -Once you're familiar with the editor, you can find various project settings in the Overleaf menu, accessed via the button in the very top left of the editor. To view tutorials, user guides, and further documentation, please visit our \\href{https://www.overleaf.com/learn}{help library}, or head to our plans page to \\href{https://www.overleaf.com/user/subscription/plans}{choose your plan}. - -\\begin{enumerate} - \\item The labels consists of sequential numbers - \\begin{itemize} - \\item The individual entries are indicated with a black dot, a so-called bullet - \\item The text in the entries may be of any length - \\begin{description} - \\item[Note:] I would like to describe something here - \\item[Caveat!] And give a warning - \\end{description} - \\end{itemize} - \\item The numbers starts at 1 with each use of the \\text{enumerate} environment -\\end{enumerate} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -\\bibliographystyle{alpha} -\\bibliography{sample} - -\\end{document}` - -export default { - title: 'History / Document Diff Viewer', - component: DocumentDiffViewer, - args: { doc, highlights }, - argTypes: { - doc: { - table: { disable: true }, - }, - highlights: { - table: { disable: true }, - }, - }, - decorators: [ - ScopeDecorator, - (Story: React.ComponentType) => ( -
- -
- ), - ], -} - -export const Highlights = ( - args: React.ComponentProps -) => { - return -} - -export const ScrollToFirstHighlight = ( - args: React.ComponentProps -) => { - const lastHighlightOnly = args.highlights.slice(-1) - return -} diff --git a/services/web/frontend/stories/history/history-version.stories.tsx b/services/web/frontend/stories/history/history-version.stories.tsx deleted file mode 100644 index 65c2a58d60..0000000000 --- a/services/web/frontend/stories/history/history-version.stories.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import HistoryVersionComponent from '../../js/features/history/components/change-list/history-version' -import { ScopeDecorator } from '../decorators/scope' -import { HistoryProvider } from '../../js/features/history/context/history-context' -import { disableControlsOf } from '../utils/arg-types' - -const update = { - fromV: 3, - toV: 4, - meta: { - users: [ - { - first_name: 'john.doe', - last_name: '', - email: 'john.doe@test.com', - id: '631710ab1094c5002647184e', - }, - ], - start_ts: 1681220036419, - end_ts: 1681220036419, - }, - labels: [ - { - id: '643561cdfa2b2beac88f0024', - comment: 'tag-1', - version: 4, - user_id: '123', - created_at: '2023-04-11T13:34:05.856Z', - user_display_name: 'john.doe', - }, - { - id: '643561d1fa2b2beac88f0025', - comment: 'tag-2', - version: 4, - user_id: '123', - created_at: '2023-04-11T13:34:09.280Z', - user_display_name: 'john.doe', - }, - ], - pathnames: [], - project_ops: [{ add: { pathname: 'name.tex' }, atV: 3 }], -} - -export const HistoryVersion = ( - args: React.ComponentProps -) => { - return ( - - - - ) -} - -export default { - title: 'History / Change list', - component: HistoryVersionComponent, - args: { - update, - currentUserId: '1', - projectId: '123', - comparing: false, - faded: false, - showDivider: false, - selectionState: false, - setSelection: () => {}, - dropdownOpen: false, - dropdownActive: false, - setActiveDropdownItem: () => {}, - closeDropdownForItem: () => {}, - }, - argTypes: { - ...disableControlsOf( - 'update', - 'currentUserId', - 'projectId', - 'setSelection', - 'dropdownOpen', - 'dropdownActive', - 'setActiveDropdownItem', - 'closeDropdownForItem' - ), - }, - decorators: [ - ScopeDecorator, - (Story: React.ComponentType) => ( -
-
-
- -
-
-
- ), - ], -} diff --git a/services/web/frontend/stories/history/label-version.stories.tsx b/services/web/frontend/stories/history/label-version.stories.tsx deleted file mode 100644 index a72bab27a8..0000000000 --- a/services/web/frontend/stories/history/label-version.stories.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import LabelListItemComponent from '../../js/features/history/components/change-list/label-list-item' -import { ScopeDecorator } from '../decorators/scope' -import { HistoryProvider } from '../../js/features/history/context/history-context' -import { disableControlsOf } from '../utils/arg-types' - -const labels = [ - { - id: '643561cdfa2b2beac88f0024', - comment: 'tag-1', - version: 1, - user_id: '123', - created_at: '2023-04-11T13:34:05.856Z', - user_display_name: 'john.doe', - }, - { - id: '643561d1fa2b2beac88f0025', - comment: 'tag-2', - version: 1, - user_id: '123', - created_at: '2023-04-11T13:34:09.280Z', - user_display_name: 'john.doe', - }, -] - -export const LabelVersion = ( - args: React.ComponentProps -) => { - return ( - - - - ) -} - -export default { - title: 'History / Change list', - component: LabelListItemComponent, - args: { - labels, - version: 1, - currentUserId: '1', - projectId: '123', - comparing: false, - selectionState: false, - selectable: false, - setSelection: () => {}, - dropdownOpen: false, - dropdownActive: false, - setActiveDropdownItem: () => {}, - closeDropdownForItem: () => {}, - }, - argTypes: { - ...disableControlsOf( - 'labels', - 'version', - 'currentUserId', - 'projectId', - 'setSelection', - 'dropdownOpen', - 'dropdownActive', - 'setActiveDropdownItem', - 'closeDropdownForItem' - ), - }, - decorators: [ - ScopeDecorator, - (Story: React.ComponentType) => ( -
-
-
- -
-
-
- ), - ], -} diff --git a/services/web/frontend/stories/history/toggle-switch.stories.tsx b/services/web/frontend/stories/history/toggle-switch.stories.tsx deleted file mode 100644 index 5fe673db55..0000000000 --- a/services/web/frontend/stories/history/toggle-switch.stories.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { useState } from 'react' -import ToggleSwitchComponent from '../../js/features/history/components/change-list/toggle-switch' -import { ScopeDecorator } from '../decorators/scope' -import { HistoryProvider } from '../../js/features/history/context/history-context' - -export const HistoryAndLabelsToggleSwitch = () => { - const [labelsOnly, setLabelsOnly] = useState(false) - - return ( - - - - ) -} - -export default { - title: 'History / Change list', - component: ToggleSwitchComponent, - argTypes: { - labelsOnly: { - table: { - disable: true, - }, - }, - setLabelsOnly: { - table: { - disable: true, - }, - }, - }, - decorators: [ - ScopeDecorator, - (Story: React.ComponentType) => ( -
-
-
- -
-
-
- ), - ], -} diff --git a/services/web/frontend/stories/hooks/use-fetch-mock.tsx b/services/web/frontend/stories/hooks/use-fetch-mock.tsx deleted file mode 100644 index 304d9e0273..0000000000 --- a/services/web/frontend/stories/hooks/use-fetch-mock.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { useLayoutEffect } from 'react' -import fetchMock from 'fetch-mock' - -/** - * Run callback to mock fetch routes, call removeRoutes() and unmockGlobal() when unmounted - */ -export default function useFetchMock( - callback: (value: typeof fetchMock) => void -) { - fetchMock.mockGlobal() - - useLayoutEffect(() => { - fetchMock.mockGlobal() - callback(fetchMock) - return () => { - fetchMock.removeRoutes() - fetchMock.unmockGlobal() - } - }, [callback]) -} diff --git a/services/web/frontend/stories/hooks/use-meta.tsx b/services/web/frontend/stories/hooks/use-meta.tsx deleted file mode 100644 index 0ebbe9f29f..0000000000 --- a/services/web/frontend/stories/hooks/use-meta.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { PartialMeta } from '@/utils/meta' - -/** - * Set values on window.metaAttributesCache, for use in Storybook stories - */ -export const useMeta = (meta: PartialMeta) => { - for (const [key, value] of Object.entries(meta)) { - window.metaAttributesCache.set(key as keyof PartialMeta, value) - } -} diff --git a/services/web/frontend/stories/hooks/use-scope.tsx b/services/web/frontend/stories/hooks/use-scope.tsx deleted file mode 100644 index f2450a38a4..0000000000 --- a/services/web/frontend/stories/hooks/use-scope.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { merge } from 'lodash' -import { useLayoutEffect, useRef } from 'react' - -/** - * Merge properties with the scope object, for use in Storybook stories - */ -export const useScope = (scope: Record) => { - const scopeRef = useRef(null) - if (scopeRef.current === null) { - scopeRef.current = scope - } - - useLayoutEffect(() => { - if (scopeRef.current) { - for (const [path, value] of Object.entries(scopeRef.current)) { - let existingValue: typeof value | undefined - try { - existingValue = window.overleaf.unstable.store.get(path) - } catch { - // allowed not to exist - } - if (typeof existingValue === 'object' && typeof value === 'object') { - window.overleaf.unstable.store.set(path, merge(existingValue, value)) - } else { - window.overleaf.unstable.store.set(path, value) - } - } - } - }, []) -} diff --git a/services/web/frontend/stories/hotkeys-modal.stories.tsx b/services/web/frontend/stories/hotkeys-modal.stories.tsx deleted file mode 100644 index 026624ceef..0000000000 --- a/services/web/frontend/stories/hotkeys-modal.stories.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { ComponentProps } from 'react' -import HotkeysModal from '../js/features/hotkeys-modal/components/hotkeys-modal' - -type HotkeysModalProps = ComponentProps - -export const ReviewEnabled = (args: HotkeysModalProps) => { - return -} - -export const ReviewDisabled = (args: HotkeysModalProps) => { - return -} - -export const MacModifier = (args: HotkeysModalProps) => { - return -} - -export default { - title: 'Editor / Modals / Hotkeys', - component: HotkeysModal, - args: { - animation: false, - show: true, - isMac: false, - trackChangesVisible: true, - }, - argTypes: { - handleHide: { action: 'handleHide' }, - }, -} diff --git a/services/web/frontend/stories/loading/loading.stories.tsx b/services/web/frontend/stories/loading/loading.stories.tsx deleted file mode 100644 index 3ec57f0a1f..0000000000 --- a/services/web/frontend/stories/loading/loading.stories.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react-webpack5' -import { LoadingUI } from '@/features/ide-react/components/loading' -import { EditorProviders } from '../../../test/frontend/helpers/editor-providers' -import { PartialMeta } from '@/utils/meta' - -const meta: Meta = { - title: 'Loading Page / Loading', - component: LoadingUI, - argTypes: { - errorCode: { - control: 'select', - options: [ - '', - 'io-not-loaded', - 'unable-to-join', - 'i18n-error', - 'unhandled-error-code', - ], - }, - progress: { control: { type: 'range', min: 0, max: 100 } }, - }, -} - -export default meta - -type Story = StoryObj - -const errorMessages = { - translationIoNotLoaded: 'Could not connect to the WebSocket server', - translationLoadErrorMessage: 'Could not load translations', - translationUnableToJoin: 'Could not connect to collaboration server', -} - -export const LoadingPage: Story = { - render: args => { - for (const [key, value] of Object.entries(errorMessages)) { - window.metaAttributesCache.set(`ol-${key}` as keyof PartialMeta, value) - } - return ( - - - - ) - }, -} diff --git a/services/web/frontend/stories/modals/create-file/create-file-modal-decorator.jsx b/services/web/frontend/stories/modals/create-file/create-file-modal-decorator.jsx deleted file mode 100644 index a70a507436..0000000000 --- a/services/web/frontend/stories/modals/create-file/create-file-modal-decorator.jsx +++ /dev/null @@ -1,118 +0,0 @@ -import { useEffect } from 'react' -import FileTreeContext from '../../../js/features/file-tree/components/file-tree-context' -import FileTreeCreateNameProvider from '../../../js/features/file-tree/contexts/file-tree-create-name' -import FileTreeCreateFormProvider from '../../../js/features/file-tree/contexts/file-tree-create-form' -import { useFileTreeActionable } from '../../../js/features/file-tree/contexts/file-tree-actionable' -import PropTypes from 'prop-types' - -const defaultFileTreeContextProps = { - refProviders: { mendeley: false, zotero: false }, - setRefProviderEnabled: provider => { - console.log(`ref provider ${provider} enabled`) - }, - setStartedFreeTrial: () => { - console.log('started free trial') - }, - initialSelectedEntityId: 'entity-1', - onSelect: () => { - console.log('selected') - }, -} - -export const mockCreateFileModalFetch = fetchMock => - fetchMock - .get('path:/user/projects', { - projects: [ - { - _id: 'project-1', - name: 'Project One', - }, - { - _id: 'project-2', - name: 'Project Two', - }, - ], - }) - .get('path:/mendeley/groups', { - groups: [ - { - id: 'group-1', - name: 'Group One', - }, - { - id: 'group-2', - name: 'Group Two', - }, - ], - }) - .get('path:/zotero/groups', { - groups: [ - { - id: 'group-1', - name: 'Group One', - }, - { - id: 'group-2', - name: 'Group Two', - }, - ], - }) - .get('express:/project/:projectId/entities', { - entities: [ - { - path: '/foo.tex', - }, - { - path: '/bar.tex', - }, - ], - }) - .post('express:/project/:projectId/doc', (path, req) => { - console.log({ path, req }) - return 204 - }) - .post('express:/project/:projectId/upload', (path, req) => { - console.log({ path, req }) - return 204 - }) - .post('express:/project/:projectId/linked_file', (path, req) => { - console.log({ path, req }) - return 204 - }) - -export const createFileModalDecorator = - (fileTreeContextProps = {}, createMode = 'doc') => - // eslint-disable-next-line react/display-name - Story => { - return ( - - - - - - - - - - ) - } - -function OpenCreateFileModal({ children, createMode }) { - const { startCreatingFile } = useFileTreeActionable() - - useEffect(() => { - startCreatingFile(createMode) - }, [createMode]) // eslint-disable-line react-hooks/exhaustive-deps - - return <>{children} -} -OpenCreateFileModal.propTypes = { - createMode: PropTypes.string, - children: PropTypes.oneOfType([ - PropTypes.arrayOf(PropTypes.node), - PropTypes.node, - ]).isRequired, -} diff --git a/services/web/frontend/stories/modals/create-file/create-file-modal-footer.stories.jsx b/services/web/frontend/stories/modals/create-file/create-file-modal-footer.stories.jsx deleted file mode 100644 index 224850f494..0000000000 --- a/services/web/frontend/stories/modals/create-file/create-file-modal-footer.stories.jsx +++ /dev/null @@ -1,62 +0,0 @@ -import { - ModalFooterDecorator, - ModalContentDecorator, -} from '../modal-decorators' -import { FileTreeModalCreateFileFooterContent } from '../../../js/features/file-tree/components/file-tree-create/file-tree-modal-create-file-footer' - -export const Valid = args => - -export const Invalid = args => ( - -) -Invalid.args = { - valid: false, -} - -export const Inflight = args => ( - -) -Inflight.args = { - inFlight: true, -} - -export const FileLimitWarning = args => ( - -) -FileLimitWarning.args = { - fileCount: { - status: 'warning', - value: 1990, - limit: 2000, - }, -} - -export const FileLimitError = args => ( - -) -FileLimitError.args = { - fileCount: { - status: 'error', - value: 2000, - limit: 2000, - }, -} - -export default { - title: 'Editor / Modals / Create File / Footer', - component: FileTreeModalCreateFileFooterContent, - args: { - fileCount: { - status: 'success', - limit: 10, - value: 1, - }, - valid: true, - inFlight: false, - newFileCreateMode: 'doc', - }, - argTypes: { - cancel: { action: 'cancel' }, - }, - decorators: [ModalFooterDecorator, ModalContentDecorator], -} diff --git a/services/web/frontend/stories/modals/create-file/create-file-modal.stories.jsx b/services/web/frontend/stories/modals/create-file/create-file-modal.stories.jsx deleted file mode 100644 index ba5ad45d22..0000000000 --- a/services/web/frontend/stories/modals/create-file/create-file-modal.stories.jsx +++ /dev/null @@ -1,89 +0,0 @@ -import { - createFileModalDecorator, - mockCreateFileModalFetch, -} from './create-file-modal-decorator' -import FileTreeModalCreateFile from '../../../js/features/file-tree/components/modals/file-tree-modal-create-file' -import useFetchMock from '../../hooks/use-fetch-mock' -import { ScopeDecorator } from '../../decorators/scope' -import { useScope } from '../../hooks/use-scope' -import getMeta from '@/utils/meta' - -export const MinimalFeatures = args => { - useFetchMock(mockCreateFileModalFetch) - Object.assign(getMeta('ol-ExposedSettings'), { - hasLinkUrlFeature: false, - hasLinkedProjectFileFeature: false, - hasLinkedProjectOutputFileFeature: false, - }) - - return -} -MinimalFeatures.decorators = [createFileModalDecorator()] - -export const WithExtraFeatures = args => { - useFetchMock(mockCreateFileModalFetch) - - getMeta('ol-ExposedSettings').hasLinkUrlFeature = true - - return -} -WithExtraFeatures.decorators = [ - createFileModalDecorator({ - refProviders: { mendeley: true, zotero: true }, - }), -] - -export const ErrorImportingFileFromExternalURL = args => { - useFetchMock(fetchMock => { - mockCreateFileModalFetch(fetchMock) - - fetchMock.post('express:/project/:projectId/linked_file', 500) - }) - - getMeta('ol-ExposedSettings').hasLinkUrlFeature = true - - return -} -ErrorImportingFileFromExternalURL.decorators = [createFileModalDecorator()] - -export const ErrorImportingFileFromReferenceProvider = args => { - useFetchMock(fetchMock => { - mockCreateFileModalFetch(fetchMock) - - fetchMock.post('express:/project/:projectId/linked_file', 500) - }) - - return -} -ErrorImportingFileFromReferenceProvider.decorators = [ - createFileModalDecorator({ - refProviders: { mendeley: true, zotero: true }, - }), -] - -export const FileLimitReached = args => { - useFetchMock(mockCreateFileModalFetch) - - useScope({ - project: { - rootFolder: { - _id: 'root-folder-id', - name: 'rootFolder', - docs: Array.from({ length: 10 }, (_, index) => ({ - _id: `entity-${index}`, - })), - fileRefs: [], - folders: [], - }, - }, - }) - - return -} -FileLimitReached.decorators = [createFileModalDecorator()] - -export default { - title: 'Editor / Modals / Create File', - component: FileTreeModalCreateFile, - decorators: [ScopeDecorator], -} diff --git a/services/web/frontend/stories/modals/create-file/create-file-name-input.stories.jsx b/services/web/frontend/stories/modals/create-file/create-file-name-input.stories.jsx deleted file mode 100644 index 8f68e2d7b8..0000000000 --- a/services/web/frontend/stories/modals/create-file/create-file-name-input.stories.jsx +++ /dev/null @@ -1,67 +0,0 @@ -import FileTreeCreateNameInput from '../../../js/features/file-tree/components/file-tree-create/file-tree-create-name-input' -import FileTreeCreateNameProvider from '../../../js/features/file-tree/contexts/file-tree-create-name' -import { - BlockedFilenameError, - DuplicateFilenameError, -} from '../../../js/features/file-tree/errors' -import { ModalBodyDecorator, ModalContentDecorator } from '../modal-decorators' - -export const DefaultLabel = args => ( - - - -) - -export const CustomLabel = args => ( - - - -) -CustomLabel.args = { - label: 'File Name in this Project', -} - -export const FocusName = args => ( - - - -) -FocusName.args = { - focusName: true, -} - -export const CustomPlaceholder = args => ( - - - -) -CustomPlaceholder.args = { - placeholder: 'Enter a file name…', -} - -export const DuplicateError = args => ( - - - -) -DuplicateError.args = { - error: new DuplicateFilenameError(), -} - -export const BlockedError = args => ( - - - -) -BlockedError.args = { - error: new BlockedFilenameError(), -} - -export default { - title: 'Editor / Modals / Create File / File Name Input', - component: FileTreeCreateNameInput, - decorators: [ModalBodyDecorator, ModalContentDecorator], - args: { - inFlight: false, - }, -} diff --git a/services/web/frontend/stories/modals/create-file/error-message.stories.jsx b/services/web/frontend/stories/modals/create-file/error-message.stories.jsx deleted file mode 100644 index 867a2b9e74..0000000000 --- a/services/web/frontend/stories/modals/create-file/error-message.stories.jsx +++ /dev/null @@ -1,106 +0,0 @@ -import ErrorMessage from '../../../js/features/file-tree/components/file-tree-create/error-message' -import { FetchError } from '../../../js/infrastructure/fetch-json' -import { - BlockedFilenameError, - DuplicateFilenameError, - InvalidFilenameError, -} from '../../../js/features/file-tree/errors' - -export const KeyedErrors = () => { - return ( - <> - - - - - {/* */} - - - ) -} - -export const FetchStatusErrors = () => { - return ( - <> - - - - - - ) -} - -export const FetchDataErrors = () => { - return ( - <> - - - - ) -} - -export const SpecificClassErrors = () => { - return ( - <> - - - - - - ) -} - -export default { - title: 'Editor / Modals / Create File / Error Message', - component: ErrorMessage, -} diff --git a/services/web/frontend/stories/modals/modal-decorators.jsx b/services/web/frontend/stories/modals/modal-decorators.jsx deleted file mode 100644 index 53b304673a..0000000000 --- a/services/web/frontend/stories/modals/modal-decorators.jsx +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Wrap modal content in modal classes, without modal behaviours - */ - -export function ModalContentDecorator(Story) { - return ( -
-
- -
-
- ) -} - -export function ModalBodyDecorator(Story) { - return ( -
- -
- ) -} - -export function ModalFooterDecorator(Story) { - return ( -
- -
- ) -} diff --git a/services/web/frontend/stories/modals/unsaved-docs-locked-modal.stories.tsx b/services/web/frontend/stories/modals/unsaved-docs-locked-modal.stories.tsx deleted file mode 100644 index 929164e5d5..0000000000 --- a/services/web/frontend/stories/modals/unsaved-docs-locked-modal.stories.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { Meta, StoryObj } from '@storybook/react-webpack5' -import { UnsavedDocsLockedAlert } from '@/features/ide-react/components/unsaved-docs/unsaved-docs-locked-alert' -import { ScopeDecorator } from '../decorators/scope' - -export default { - title: 'Editor / Modals / Unsaved Docs Locked', - component: UnsavedDocsLockedAlert, - decorators: [Story => ScopeDecorator(Story)], -} satisfies Meta - -type Story = StoryObj - -export const Locked: Story = {} diff --git a/services/web/frontend/stories/online-users.stories.tsx b/services/web/frontend/stories/online-users.stories.tsx deleted file mode 100644 index 035c8dcfa0..0000000000 --- a/services/web/frontend/stories/online-users.stories.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { Meta } from '@storybook/react-webpack5' -import { OnlineUser } from '@/features/ide-react/context/online-users-context' -import { OnlineUsersWidget } from '@/features/editor-navigation-toolbar/components/online-users-widget' - -const NAMES = [ - 'Alice', - 'Bob', - 'Charlie', - 'Dave', - 'Erin', - 'Frank', - 'Grace', - 'Heidi', - 'Ivan', - 'Judy', - 'Mallory', - 'Niaj', - 'Olivia', - 'Peggy', - 'Rupert', -] - -const generateUser = (_: any, index: number): OnlineUser => { - const name = NAMES[index % NAMES.length] - return { - user_id: `user_${'b'.repeat(index)}`, - name, - id: `user-${index}`, - email: `${name.toLowerCase()}@example.com`, - } -} - -export const OnlineUsers = ({ users }: { users: number }) => { - const generatedUsers = Array.from({ length: users }, generateUser) - return ( -
- {}) as any} - /> -
- ) -} - -const meta: Meta = { - title: 'Editor / Online Users Widget', - args: { - users: 6, - }, -} - -export default meta diff --git a/services/web/frontend/stories/outline.stories.jsx b/services/web/frontend/stories/outline.stories.jsx deleted file mode 100644 index 03ed349e12..0000000000 --- a/services/web/frontend/stories/outline.stories.jsx +++ /dev/null @@ -1,61 +0,0 @@ -import OutlinePane from '../js/features/outline/components/outline-pane' -import { ScopeDecorator } from './decorators/scope' - -export const Basic = args => -Basic.args = { - outline: [{ line: 1, title: 'Hello', level: 1 }], -} - -export const Nested = args => -Nested.args = { - outline: [ - { - line: 1, - title: 'Section', - level: 1, - children: [ - { - line: 2, - title: 'Subsection', - level: 2, - children: [ - { - line: 3, - title: 'Subsubsection', - level: 3, - }, - ], - }, - ], - }, - ], -} - -export const NoSections = args => -NoSections.args = {} - -export const NonTexFile = args => -NonTexFile.args = { - isTexFile: false, -} - -export const PartialResult = args => -PartialResult.args = { - isPartial: true, -} - -export default { - title: 'Editor / Outline', - component: OutlinePane, - argTypes: { - jumpToLine: { action: 'jumpToLine' }, - onToggle: { action: 'onToggle' }, - toggleExpanded: { action: 'toggleExpanded' }, - }, - args: { - isTexFile: true, - outline: [], - expanded: true, - }, - decorators: [ScopeDecorator], -} diff --git a/services/web/frontend/stories/pdf-log-entry.stories.tsx b/services/web/frontend/stories/pdf-log-entry.stories.tsx deleted file mode 100644 index 95a3e33fa9..0000000000 --- a/services/web/frontend/stories/pdf-log-entry.stories.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import PdfLogEntry from '@/features/pdf-preview/components/pdf-log-entry' -import type { Meta, StoryObj } from '@storybook/react-webpack5' -import { ruleIds } from '@/ide/human-readable-logs/HumanReadableLogsHints' -import { ScopeDecorator } from './decorators/scope' -import { useMeta } from './hooks/use-meta' -import { FC, ReactNode } from 'react' -import { EditorViewContext } from '@/features/ide-react/context/editor-view-context' -import { EditorView } from '@codemirror/view' -import { LogEntry } from '@/features/pdf-preview/util/types' - -const fakeSourceLocation = { - file: 'file.tex', - line: 12, - column: 5, -} - -const fakeLogEntry: LogEntry = { - key: 'fake', - ruleId: 'hint_misplaced_alignment_tab_character', - message: 'Fake message', - messageComponent: 'Fake message component', - content: 'Fake content', - type: 'Error: ', - level: 'error', - contentDetails: ['Fake detail 1', 'Fake detail 2'], - file: 'fake.tex', - line: 12, - column: 5, - raw: 'Fake raw', -} - -const fakeArgs = { - headerTitle: 'PDF Preview', - formattedContent: 'This is a log entry', - level: 'error' as const, - extraInfoURL: 'https://example.com', - showSourceLocationLink: true, - rawContent: 'This is a raw log entry', - contentDetails: ['detail 1', 'detail 2'], - ruleId: 'hint_misplaced_alignment_tab_character' as const, - sourceLocation: fakeSourceLocation, - logEntry: fakeLogEntry, - logType: 'Fake type', -} - -const meta: Meta = { - title: 'Editor / PDF Preview / Logs', - component: PdfLogEntry, - // @ts-ignore - decorators: [ScopeDecorator], - argTypes: { - ruleId: { control: 'select', options: [...ruleIds, 'other'] }, - }, - args: fakeArgs, -} - -export default meta - -type Story = StoryObj - -const MockEditorViewProvider: FC = ({ children }) => { - const value = { - view: new EditorView({ - doc: '\\begin{document', - }), - setView: () => {}, - } - - return ( - - {children} - - ) -} - -const Provider: FC> = ({ - children, -}) => { - useMeta({ 'ol-showAiFeatures': true }) - return ( - -
{children}
-
- ) -} - -export const PdfLogEntryWithControls: Story = { - render: args => ( - - - - ), -} diff --git a/services/web/frontend/stories/pdf-preview-error-boundary.stories.jsx b/services/web/frontend/stories/pdf-preview-error-boundary.stories.jsx deleted file mode 100644 index 6ea6b4b3ab..0000000000 --- a/services/web/frontend/stories/pdf-preview-error-boundary.stories.jsx +++ /dev/null @@ -1,20 +0,0 @@ -import PdfPreviewErrorBoundaryFallback from '../js/features/pdf-preview/components/pdf-preview-error-boundary-fallback' -import { ScopeDecorator } from './decorators/scope' - -export default { - title: 'Editor / PDF Preview / Error Boundary', - component: PdfPreviewErrorBoundaryFallback, - decorators: [ScopeDecorator], -} - -export const PreviewErrorBoundary = () => { - return -} - -export const PdfErrorBoundary = () => { - return -} - -export const LogsErrorBoundary = () => { - return -} diff --git a/services/web/frontend/stories/pdf-preview-messages.stories.jsx b/services/web/frontend/stories/pdf-preview-messages.stories.jsx deleted file mode 100644 index b25b9d121b..0000000000 --- a/services/web/frontend/stories/pdf-preview-messages.stories.jsx +++ /dev/null @@ -1,27 +0,0 @@ -import { ScopeDecorator } from './decorators/scope' -import { useLocalCompileContext } from '../js/shared/context/local-compile-context' -import { useEffect } from 'react' -import { PdfPreviewMessages } from '../js/features/pdf-preview/components/pdf-preview-messages' -import CompileTimeWarningUpgradePrompt from '@/features/pdf-preview/components/compile-time-warning-upgrade-prompt' - -export default { - title: 'Editor / PDF Preview / Messages', - component: PdfPreviewMessages, - decorators: [ScopeDecorator], -} - -export const Dismissible = () => { - const { setShowCompileTimeWarning } = useLocalCompileContext() - - useEffect(() => { - setShowCompileTimeWarning(true) - }, [setShowCompileTimeWarning]) - - return ( -
- - - -
- ) -} diff --git a/services/web/frontend/stories/pdf-preview-messages.stories.tsx b/services/web/frontend/stories/pdf-preview-messages.stories.tsx deleted file mode 100644 index d04c8ff67a..0000000000 --- a/services/web/frontend/stories/pdf-preview-messages.stories.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { ScopeDecorator } from './decorators/scope' -import { PdfPreviewMessages } from '@/features/pdf-preview/components/pdf-preview-messages' -import { CompileTimeWarningUpgradePromptInner } from '@/features/pdf-preview/components/compile-time-warning-upgrade-prompt-inner' - -export default { - title: 'Editor / PDF Preview / Messages', - component: PdfPreviewMessages, - decorators: [ - ScopeDecorator, - (Story: any) => ( -
- - - -
- ), - ], -} - -export const CompileTimeoutWarningActive = (args: any) => { - return -} -CompileTimeoutWarningActive.argTypes = { - handleDismissWarning: { action: 'dismiss warning' }, -} diff --git a/services/web/frontend/stories/pdf-preview.stories.jsx b/services/web/frontend/stories/pdf-preview.stories.jsx deleted file mode 100644 index bff6253911..0000000000 --- a/services/web/frontend/stories/pdf-preview.stories.jsx +++ /dev/null @@ -1,375 +0,0 @@ -import { useCallback, useMemo, useState } from 'react' -import useFetchMock from './hooks/use-fetch-mock' -import OLButton from '@/shared/components/ol/ol-button' -import PdfPreviewPane from '../js/features/pdf-preview/components/pdf-preview-pane' -import PdfPreview from '../js/features/pdf-preview/components/pdf-preview' -import PdfFileList from '../js/features/pdf-preview/components/pdf-file-list' -import { buildFileList } from '../js/features/pdf-preview/util/file-list' -import PdfLogsViewer from '../js/features/pdf-preview/components/pdf-logs-viewer' -import PdfPreviewError from '../js/features/pdf-preview/components/pdf-preview-error' -import PdfPreviewHybridToolbar from '../js/features/pdf-preview/components/pdf-preview-hybrid-toolbar' -import { useDetachCompileContext as useCompileContext } from '../js/shared/context/detach-compile-context' -import { - dispatchDocChanged, - mockBuildFile, - mockClearCache, - mockCompile, - mockCompileError, - mockCompileValidationIssues, - mockEventTracking, - outputFiles, -} from './fixtures/compile' -import { cloneDeep } from 'lodash' -import { ScopeDecorator } from './decorators/scope' -import { PdfPreviewProvider } from '@/features/pdf-preview/components/pdf-preview-provider' -import { - Dropdown, - DropdownMenu, -} from '@/shared/components/dropdown/dropdown-menu' - -export default { - title: 'Editor / PDF Preview', - component: PdfPreview, - subcomponents: { - PdfPreviewHybridToolbar, - PdfFileList, - PdfPreviewError, - }, - decorators: [ScopeDecorator], -} - -export const Interactive = () => { - useFetchMock(fetchMock => { - mockCompile(fetchMock) - mockBuildFile(fetchMock) - mockClearCache(fetchMock) - }) - - const Inner = () => { - const context = useCompileContext() - - const { setHasLintingError } = context - - const toggleLintingError = useCallback(() => { - setHasLintingError(value => !value) - }, [setHasLintingError]) - - const values = useMemo(() => { - const entries = Object.entries(context).sort((a, b) => { - return a[0].localeCompare(b[0]) - }) - - const values = { boolean: [], other: [] } - - for (const entry of entries) { - const type = typeof entry[1] - - if (type === 'boolean') { - values.boolean.push(entry) - } else if (type !== 'function') { - values.other.push(entry) - } - } - - return values - }, [context]) - - return ( -
-
-
- - - {values.boolean.map(([key, value]) => { - return ( - - - - - ) - })} - -
{value ? '🟢' : '🔴'}{key}
- -
-
- - trigger doc change - - - toggle linting error - -
-
-
- -
- - - {values.other.map(([key, value]) => { - return ( - - - - - ) - })} - -
- {key} - -
-                          {JSON.stringify(value, null, 2)}
-                        
-
-
-
-
- ) - } - - return ( -
- - -
- ) -} - -const compileStatuses = [ - 'autocompile-backoff', - 'clear-cache', - 'clsi-maintenance', - 'compile-in-progress', - 'exited', - 'failure', - 'generic', - 'project-too-large', - 'rate-limited', - 'success', - 'terminated', - 'timedout', - 'too-recently-compiled', - 'unavailable', - 'validation-problems', - 'foo', -] - -export const CompileError = () => { - const [status, setStatus] = useState('success') - - useFetchMock(fetchMock => { - mockCompileError(fetchMock, status, 0) - mockBuildFile(fetchMock) - }) - - const Inner = () => { - const { startCompile } = useCompileContext() - - const handleStatusChange = useCallback( - event => { - setStatus(event.target.value) - window.setTimeout(() => { - startCompile() - }, 0) - }, - [startCompile] - ) - - return ( -
- -
- ) - } - - return ( - <> - - - - ) -} - -const compileErrors = [ - 'autocompile-backoff', - 'clear-cache', - 'clsi-maintenance', - 'compile-in-progress', - 'exited', - 'failure', - 'generic', - 'project-too-large', - 'rate-limited', - 'success', - 'terminated', - 'timedout', - 'too-recently-compiled', - 'unavailable', - 'validation-problems', - 'foo', -] - -export const DisplayError = () => { - useFetchMock(fetchMock => { - mockCompile(fetchMock) - }) - - return ( -
- {compileErrors.map(error => ( -
-
{error}
- -
- ))} -
- ) -} - -export const HybridToolbar = () => { - useFetchMock(fetchMock => { - mockCompile(fetchMock, 500) - mockBuildFile(fetchMock) - mockEventTracking(fetchMock) - }) - - return ( -
- -
- ) -} - -export const FileList = () => { - const fileList = useMemo(() => { - return buildFileList(cloneDeep(outputFiles), {}) - }, []) - - return ( - - - - - - ) -} - -export const Logs = () => { - useFetchMock(fetchMock => { - mockCompileError(fetchMock, 400, 0) - mockBuildFile(fetchMock) - mockClearCache(fetchMock) - }) - - return ( -
- - - -
- ) -} - -const validationProblems = { - sizeCheck: { - resources: [ - { path: 'foo/bar', kbSize: 76221 }, - { path: 'bar/baz', kbSize: 2342 }, - ], - }, - mainFile: true, - conflictedPaths: [ - { - path: 'foo/bar', - }, - { - path: 'foo/baz', - }, - ], -} - -export const ValidationIssues = () => { - useFetchMock(fetchMock => { - mockCompileValidationIssues(fetchMock, validationProblems, 0) - mockBuildFile(fetchMock) - }) - - return -} diff --git a/services/web/frontend/stories/pdf-viewer.stories.jsx b/services/web/frontend/stories/pdf-viewer.stories.jsx deleted file mode 100644 index b527d02612..0000000000 --- a/services/web/frontend/stories/pdf-viewer.stories.jsx +++ /dev/null @@ -1,50 +0,0 @@ -import useFetchMock from './hooks/use-fetch-mock' -import PdfSynctexControls from '../js/features/pdf-preview/components/pdf-synctex-controls' -import PdfViewer from '../js/features/pdf-preview/components/pdf-viewer' -import { - mockBuildFile, - mockCompile, - mockSynctex, - mockValidPdf, -} from './fixtures/compile' -import { useEffect, Suspense } from 'react' -import { ScopeDecorator } from './decorators/scope' -import { PdfPreviewProvider } from '@/features/pdf-preview/components/pdf-preview-provider' - -export default { - title: 'Editor / PDF Viewer', - component: PdfViewer, - decorators: [ScopeDecorator], -} - -export const Interactive = () => { - useFetchMock(fetchMock => { - mockCompile(fetchMock) - mockBuildFile(fetchMock) - mockValidPdf(fetchMock) - mockSynctex(fetchMock) - }) - - useEffect(() => { - window.dispatchEvent( - new CustomEvent(`cursor:editor:update`, { - detail: { row: 10, position: 10 }, - }) - ) - }, []) - - return ( -
-
- - - - - -
-
- -
-
- ) -} diff --git a/services/web/frontend/stories/project-list/add-affiliation.stories.tsx b/services/web/frontend/stories/project-list/add-affiliation.stories.tsx deleted file mode 100644 index 75adc967e1..0000000000 --- a/services/web/frontend/stories/project-list/add-affiliation.stories.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import AddAffiliation from '../../js/features/project-list/components/add-affiliation' -import { ProjectListProvider } from '../../js/features/project-list/context/project-list-context' -import useFetchMock from '../hooks/use-fetch-mock' -import { projectsData } from '../../../test/frontend/features/project-list/fixtures/projects-data' -import getMeta from '@/utils/meta' - -export const Add = (args: any) => { - Object.assign(getMeta('ol-ExposedSettings'), { - isOverleaf: true, - }) - window.metaAttributesCache.set('ol-userAffiliations', []) - useFetchMock(fetchMock => { - fetchMock.post(/\/api\/project/, { - projects: projectsData, - totalSize: projectsData.length, - }) - }) - - return ( - - - - ) -} - -export default { - title: 'Project List / Affiliation', - component: AddAffiliation, -} diff --git a/services/web/frontend/stories/project-list/color-picker.stories.tsx b/services/web/frontend/stories/project-list/color-picker.stories.tsx deleted file mode 100644 index b7141c2098..0000000000 --- a/services/web/frontend/stories/project-list/color-picker.stories.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { ColorPicker } from '../../js/features/project-list/components/color-picker/color-picker' -import { ColorPickerProvider } from '../../js/features/project-list/context/color-picker-context' - -export const Select = (args: any) => { - return ( - - - - ) -} - -export default { - title: 'Project List / Color Picker', - component: ColorPicker, -} diff --git a/services/web/frontend/stories/project-list/compile-and-download-project-pdf.stories.tsx b/services/web/frontend/stories/project-list/compile-and-download-project-pdf.stories.tsx deleted file mode 100644 index 53fa31cdb0..0000000000 --- a/services/web/frontend/stories/project-list/compile-and-download-project-pdf.stories.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import ProjectListTable from '../../js/features/project-list/components/table/project-list-table' -import { ProjectListProvider } from '../../js/features/project-list/context/project-list-context' -import useFetchMock from '../hooks/use-fetch-mock' -import { projectsData } from '../../../test/frontend/features/project-list/fixtures/projects-data' - -export const Successful = (args: any) => { - window.metaAttributesCache.set('ol-user_id', '624333f147cfd8002622a1d3') - useFetchMock(fetchMock => { - fetchMock.post(/\/api\/project/, { - projects: projectsData, - totalSize: projectsData.length, - }) - fetchMock.post( - /\/compile/, - { - status: 'success', - compileGroup: 'standard', - clsiServerId: 'server-1', - outputFiles: [{ path: 'output.pdf', build: '123-321' }], - }, - { - delay: 1_000, - } - ) - }) - - return ( - - - - ) -} - -export const Failure = (args: any) => { - window.metaAttributesCache.set('ol-user_id', '624333f147cfd8002622a1d3') - useFetchMock(fetchMock => { - fetchMock.post(/\/api\/project/, { - projects: projectsData, - totalSize: projectsData.length, - }) - fetchMock.post( - /\/compile/, - { status: 'failure', outputFiles: [] }, - { delay: 1_000 } - ) - }) - - return ( - - - - ) -} - -export default { - title: 'Project List / PDF download', - component: ProjectListTable, - decorators: [ - (Story: any) => ( -
- -
- ), - ], -} diff --git a/services/web/frontend/stories/project-list/current-plan-widget.stories.tsx b/services/web/frontend/stories/project-list/current-plan-widget.stories.tsx deleted file mode 100644 index be9a3b1cb1..0000000000 --- a/services/web/frontend/stories/project-list/current-plan-widget.stories.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import CurrentPlanWidget from '../../js/features/project-list/components/current-plan-widget/current-plan-widget' - -export const FreePlan = (args: any) => { - window.metaAttributesCache.set('ol-usersBestSubscription', { - type: 'free', - }) - - return -} - -export const PaidPlanTrialLastDay = (args: any) => { - window.metaAttributesCache.set('ol-usersBestSubscription', { - type: 'individual', - remainingTrialDays: 1, - plan: { - name: 'Individual', - }, - subscription: { - name: 'Example Name', - }, - }) - - return -} - -export const PaidPlanRemainingDays = (args: any) => { - window.metaAttributesCache.set('ol-usersBestSubscription', { - type: 'individual', - remainingTrialDays: 5, - plan: { - name: 'Individual', - }, - subscription: { - name: 'Example Name', - }, - }) - - return -} - -export const PaidPlanActive = (args: any) => { - window.metaAttributesCache.set('ol-usersBestSubscription', { - type: 'individual', - plan: { - name: 'Individual', - }, - subscription: { - name: 'Example Name', - }, - }) - - return -} - -export const PausedPlan = (args: any) => { - window.metaAttributesCache.set('ol-usersBestSubscription', { - type: 'individual', - plan: { - name: 'Individual', - }, - subscription: { - name: 'Example Name', - recurlyStatus: { - state: 'paused', - }, - }, - }) - - return -} - -export default { - title: 'Project List / Current Plan Widget', - component: CurrentPlanWidget, -} diff --git a/services/web/frontend/stories/project-list/helpers/emails.ts b/services/web/frontend/stories/project-list/helpers/emails.ts deleted file mode 100644 index 4fadd3ca11..0000000000 --- a/services/web/frontend/stories/project-list/helpers/emails.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { merge, cloneDeep } from 'lodash' -import { type FetchMock } from 'fetch-mock' -import { UserEmailData } from '../../../../types/user-email' -import { - Institution, - Notification, -} from '../../../../types/project/dashboard/notification' -import { DeepPartial, DeepReadonly } from '../../../../types/utils' -import { Project } from '../../../../types/project/dashboard/api' -import getMeta from '@/utils/meta' - -const MOCK_DELAY = 1000 - -const fakeInstitutionData = { - email: 'email@example.com', - institutionEmail: 'institution@example.com', - institutionId: 123, - institutionName: 'Abc Institution', - requestedEmail: 'requested@example.com', -} as DeepReadonly - -export const fakeReconfirmationUsersData = { - affiliation: { - institution: { - ssoEnabled: false, - ssoBeta: false, - name: 'Abc Institution', - }, - }, - samlProviderId: 'Saml Provider', - email: 'reconfirmation-email@overleaf.com', - default: false, -} as DeepReadonly - -export function defaultSetupMocks(fetchMock: FetchMock) { - // at least one project is required to show some notifications - const projects = [{}] as Project[] - fetchMock.post(/\/api\/project/, { - status: 200, - body: { - projects, - totalSize: projects.length, - }, - }) -} - -export function setDefaultMeta() { - Object.assign(getMeta('ol-ExposedSettings'), { - emailConfirmationDisabled: false, - samlInitPath: '/fakeSaml', - appName: 'Overleaf', - }) - window.metaAttributesCache.set('ol-notificationsInstitution', []) - window.metaAttributesCache.set('ol-userEmails', []) -} - -export function errorsMocks(fetchMock: FetchMock) { - defaultSetupMocks(fetchMock) - fetchMock.post(/\/user\/emails\/*/, 500, { delay: MOCK_DELAY }) - fetchMock.post( - /\/project\/[A-Za-z0-9]+\/invite\/token\/[A-Za-z0-9]+\/accept/, - 500, - { delay: MOCK_DELAY } - ) -} - -export function setInstitutionMeta(institutionData: Partial) { - setDefaultMeta() - window.metaAttributesCache.set('ol-notificationsInstitution', [ - merge(cloneDeep(fakeInstitutionData), institutionData), - ]) -} - -export function institutionSetupMocks(fetchMock: FetchMock) { - defaultSetupMocks(fetchMock) - fetchMock.delete(/\/notifications\/*/, 200, { delay: MOCK_DELAY }) -} - -export function setCommonMeta(notificationData: DeepPartial) { - setDefaultMeta() - window.metaAttributesCache.set('ol-notifications', [notificationData]) -} - -export function commonSetupMocks(fetchMock: FetchMock) { - defaultSetupMocks(fetchMock) - fetchMock.post( - /\/project\/[A-Za-z0-9]+\/invite\/token\/[A-Za-z0-9]+\/accept/, - 200, - { delay: MOCK_DELAY } - ) -} - -export function setReconfirmationMeta() { - setDefaultMeta() - window.metaAttributesCache.set('ol-userEmails', [fakeReconfirmationUsersData]) -} - -export function reconfirmationSetupMocks(fetchMock: FetchMock) { - defaultSetupMocks(fetchMock) -} - -export function setReconfirmAffiliationMeta() { - setDefaultMeta() - window.metaAttributesCache.set( - 'ol-reconfirmedViaSAML', - fakeReconfirmationUsersData.samlProviderId - ) -} - -export function reconfirmAffiliationSetupMocks(fetchMock: FetchMock) { - defaultSetupMocks(fetchMock) - fetchMock.post(/\/api\/project/, { - status: 200, - body: { - projects: [{}], - totalSize: 0, - }, - }) -} diff --git a/services/web/frontend/stories/project-list/inr-banner.stories.tsx b/services/web/frontend/stories/project-list/inr-banner.stories.tsx deleted file mode 100644 index a59cf12492..0000000000 --- a/services/web/frontend/stories/project-list/inr-banner.stories.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import INRBanner from '@/features/project-list/components/notifications/ads/inr-banner' - -export const Default = () => { - return -} - -export default { - title: 'Project List / INR Banner', - component: INRBanner, -} diff --git a/services/web/frontend/stories/project-list/new-project-button.stories.tsx b/services/web/frontend/stories/project-list/new-project-button.stories.tsx deleted file mode 100644 index fcb19deb4c..0000000000 --- a/services/web/frontend/stories/project-list/new-project-button.stories.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import NewProjectButton from '@/features/project-list/components/new-project-button' -import { ProjectListProvider } from '@/features/project-list/context/project-list-context' -import useFetchMock from '../hooks/use-fetch-mock' -import getMeta from '@/utils/meta' -import { SplitTestProvider } from '@/shared/context/split-test-context' - -const templateLinks = [ - { - name: 'Journal articles', - url: '/gallery/tagged/academic-journal', - }, - { - name: 'Books', - url: '/gallery/tagged/book', - }, - { - name: 'Formal letters', - url: '/gallery/tagged/formal-letter', - }, - { - name: 'Assignments', - url: '/gallery/tagged/homework', - }, - { - name: 'Posters', - url: '/gallery/tagged/poster', - }, - { - name: 'Presentations', - url: '/gallery/tagged/presentation', - }, - { - name: 'Reports', - url: '/gallery/tagged/report', - }, - { - name: 'CVs and résumés', - url: '/gallery/tagged/cv', - }, - { - name: 'Theses', - url: '/gallery/tagged/thesis', - }, - { - name: 'view_all', - url: '/latex/templates', - }, -] - -export const Success = () => { - Object.assign(getMeta('ol-ExposedSettings'), { - templateLinks, - }) - - useFetchMock(fetchMock => { - fetchMock.post( - 'express:/project/new', - { - status: 200, - body: { - project_id: '123', - }, - }, - { delay: 250 } - ) - }) - - return ( - - - - - - ) -} - -export const Error = () => { - Object.assign(getMeta('ol-ExposedSettings'), { - templateLinks, - }) - - useFetchMock(fetchMock => { - fetchMock.post( - 'express:/project/new', - { - status: 400, - body: { - message: 'Something went horribly wrong!', - }, - }, - { delay: 250 } - ) - }) - - return ( - - - - - - ) -} - -export default { - title: 'Project List / New project Button', - component: NewProjectButton, -} diff --git a/services/web/frontend/stories/project-list/notifications.stories.tsx b/services/web/frontend/stories/project-list/notifications.stories.tsx deleted file mode 100644 index ea00f84681..0000000000 --- a/services/web/frontend/stories/project-list/notifications.stories.tsx +++ /dev/null @@ -1,355 +0,0 @@ -import UserNotifications from '../../js/features/project-list/components/notifications/user-notifications' -import { ProjectListProvider } from '../../js/features/project-list/context/project-list-context' -import useFetchMock from '../hooks/use-fetch-mock' -import { - commonSetupMocks, - errorsMocks, - fakeReconfirmationUsersData, - institutionSetupMocks, - reconfirmAffiliationSetupMocks, - reconfirmationSetupMocks, - setCommonMeta, - setInstitutionMeta, - setReconfirmAffiliationMeta, - setReconfirmationMeta, -} from './helpers/emails' -import { useMeta } from '../hooks/use-meta' -import { SplitTestProvider } from '@/shared/context/split-test-context' -import React, { ComponentType } from 'react' - -export const ProjectInvite = (args: any) => { - useFetchMock(commonSetupMocks) - setCommonMeta({ - templateKey: 'notification_project_invite', - messageOpts: { - projectId: '123', - projectName: 'Abc Project', - userName: 'fakeUser', - token: 'abcdef', - }, - }) - - return ( - - - - ) -} - -export const ProjectInviteNetworkError = (args: any) => { - useFetchMock(errorsMocks) - setCommonMeta({ - templateKey: 'notification_project_invite', - messageOpts: { - projectId: '123', - projectName: 'Abc Project', - userName: 'fakeUser', - token: 'abcdef', - }, - }) - - return ( - - - - ) -} - -export const Wfh2020UpgradeOffer = (args: any) => { - useFetchMock(commonSetupMocks) - setCommonMeta({ - _id: 1, - templateKey: 'wfh_2020_upgrade_offer', - }) - - return ( - - - - ) -} - -export const IPMatchedAffiliationSsoEnabled = (args: any) => { - useFetchMock(commonSetupMocks) - setCommonMeta({ - _id: 1, - templateKey: 'notification_ip_matched_affiliation', - messageOpts: { - university_name: 'Abc University', - institutionId: '456', - ssoEnabled: true, - }, - }) - - return ( - - - - ) -} - -export const IPMatchedAffiliationSsoDisabled = (args: any) => { - useFetchMock(commonSetupMocks) - setCommonMeta({ - _id: 1, - templateKey: 'notification_ip_matched_affiliation', - messageOpts: { - university_name: 'Abc University', - institutionId: '456', - ssoEnabled: false, - }, - }) - - return ( - - - - ) -} - -export const TpdsFileLimit = (args: any) => { - useFetchMock(commonSetupMocks) - setCommonMeta({ - _id: 1, - templateKey: 'notification_tpds_file_limit', - messageOpts: { - projectName: 'Abc Project', - }, - }) - - return ( - - - - ) -} - -export const DropBoxDuplicateProjectNames = (args: any) => { - useFetchMock(commonSetupMocks) - setCommonMeta({ - _id: 1, - templateKey: 'notification_dropbox_duplicate_project_names', - messageOpts: { - projectName: 'Abc Project', - }, - }) - - return ( - - - - ) -} - -export const DropBoxUnlinkedDueToLapsedReconfirmation = (args: any) => { - useFetchMock(commonSetupMocks) - setCommonMeta({ - _id: 1, - templateKey: 'notification_dropbox_unlinked_due_to_lapsed_reconfirmation', - }) - - useMeta({ - 'ol-user': { features: {} }, - }) - - return ( - - - - ) -} - -export const NotificationGroupInvitation = (args: any) => { - useFetchMock(commonSetupMocks) - setCommonMeta({ - _id: 1, - templateKey: 'notification_group_invitation', - messageOpts: { - inviterName: 'John Doe', - }, - }) - - return ( - - - - ) -} - -export const NotificationGroupInvitationCancelSubscription = (args: any) => { - useFetchMock(commonSetupMocks) - setCommonMeta({ - _id: 1, - templateKey: 'notification_group_invitation', - messageOpts: { - inviterName: 'John Doe', - }, - }) - - window.metaAttributesCache.set('ol-hasIndividualPaidSubscription', true) - - return ( - - - - ) -} - -export const NonSpecificMessage = (args: any) => { - useFetchMock(commonSetupMocks) - setCommonMeta({ _id: 1, html: 'Non specific message' }) - - return ( - - - - ) -} - -export const InstitutionSsoAvailable = (args: any) => { - useFetchMock(institutionSetupMocks) - setInstitutionMeta({ - _id: 1, - templateKey: 'notification_institution_sso_available', - }) - - return ( - - - - ) -} - -export const InstitutionSsoLinked = (args: any) => { - useFetchMock(institutionSetupMocks) - setInstitutionMeta({ - _id: 1, - templateKey: 'notification_institution_sso_linked', - }) - - return ( - - - - ) -} - -export const InstitutionSsoNonCanonical = (args: any) => { - useFetchMock(institutionSetupMocks) - setInstitutionMeta({ - _id: 1, - templateKey: 'notification_institution_sso_non_canonical', - }) - - return ( - - - - ) -} - -export const InstitutionSsoAlreadyRegistered = (args: any) => { - useFetchMock(institutionSetupMocks) - setInstitutionMeta({ - _id: 1, - templateKey: 'notification_institution_sso_already_registered', - }) - - return ( - - - - ) -} - -export const InstitutionSsoError = (args: any) => { - useFetchMock(institutionSetupMocks) - setInstitutionMeta({ - templateKey: 'notification_institution_sso_error', - error: { - message: 'message', - translatedMessage: 'Translated Message', - tryAgain: true, - }, - }) - - return ( - - - - ) -} - -export const ResendConfirmationEmail = (args: any) => { - useFetchMock(reconfirmationSetupMocks) - setReconfirmationMeta() - - return ( - - - - ) -} - -export const ResendConfirmationEmailNetworkError = (args: any) => { - useFetchMock(errorsMocks) - setReconfirmationMeta() - - return ( - - - - ) -} - -export const ReconfirmAffiliation = (args: any) => { - useFetchMock(reconfirmAffiliationSetupMocks) - setReconfirmAffiliationMeta() - window.metaAttributesCache.set('ol-allInReconfirmNotificationPeriods', [ - fakeReconfirmationUsersData, - ]) - - return ( - - - - ) -} - -export const ReconfirmAffiliationNetworkError = (args: any) => { - useFetchMock(errorsMocks) - setReconfirmAffiliationMeta() - window.metaAttributesCache.set('ol-allInReconfirmNotificationPeriods', [ - fakeReconfirmationUsersData, - ]) - - return ( - - - - ) -} - -export const ReconfirmedAffiliationSuccess = (args: any) => { - useFetchMock(reconfirmAffiliationSetupMocks) - setReconfirmAffiliationMeta() - window.metaAttributesCache.set('ol-userEmails', [fakeReconfirmationUsersData]) - - return ( - - - - ) -} - -export default { - title: 'Project List / Notifications', - component: UserNotifications, - decorators: [ - (Story: ComponentType) => ( - - - - ), - ], -} diff --git a/services/web/frontend/stories/project-list/notifications/confirm-email.stories.tsx b/services/web/frontend/stories/project-list/notifications/confirm-email.stories.tsx deleted file mode 100644 index 5bbcbcf8fb..0000000000 --- a/services/web/frontend/stories/project-list/notifications/confirm-email.stories.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { SplitTestProvider } from '@/shared/context/split-test-context' -import UserNotifications from '../../../js/features/project-list/components/notifications/user-notifications' -import { ProjectListProvider } from '../../../js/features/project-list/context/project-list-context' -import { useMeta } from '../../hooks/use-meta' -import useFetchMock from '../../hooks/use-fetch-mock' -import ConfirmEmailNotification from '@/features/project-list/components/notifications/groups/confirm-email' - -export const ConfirmEmail = (args: any) => { - useMeta({ - 'ol-userEmails': [ - { - email: 'erika.mustermann+unconfirmed-primary@example.com', - default: true, - }, - { email: 'erika.mustermann+unconfirmed@example.com' }, - { - email: 'erika.mustermann+untrusted@example.com', - lastConfirmedAt: '2019-01-01', - confirmedAt: '2019-01-01', - }, - { - email: 'erika.mustermann+mit@example.com', - affiliation: { - institution: { - id: 123, - name: 'Massachusetts Institute of Technology', - confirmed: true, - commonsAccount: true, - }, - }, - }, - ], - 'ol-user': { signUpDate: '2021-01-01' }, - 'ol-usersBestSubscription': { type: 'free' }, - 'ol-prefetchedProjectsBlob': { totalSize: 20 }, - }) - useFetchMock(fetchMock => { - fetchMock.post('/user/emails/send-confirmation-code', args.statusCode, { - delay: 500, - }) - fetchMock.post('/user/emails/confirm-code', args.statusCode, { - delay: 500, - }) - fetchMock.post('/user/emails/resend-confirmation-code', args.statusCode, { - delay: 500, - }) - }) - return ( - - - - - - ) -} - -export default { - title: 'Project List / Notifications', - component: ConfirmEmailNotification, - args: { - statusCode: 200, - }, - argTypes: { - statusCode: { type: 'select', options: [200, 400, 429] }, - }, -} diff --git a/services/web/frontend/stories/project-list/project-list.stories.tsx b/services/web/frontend/stories/project-list/project-list.stories.tsx deleted file mode 100644 index af058548cb..0000000000 --- a/services/web/frontend/stories/project-list/project-list.stories.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import ProjectListTable from '../../js/features/project-list/components/table/project-list-table' -import { ProjectListProvider } from '../../js/features/project-list/context/project-list-context' -import useFetchMock from '../hooks/use-fetch-mock' -import { - copyableProject, - projectsData, -} from '../../../test/frontend/features/project-list/fixtures/projects-data' -import { useMeta } from '../hooks/use-meta' -import { tags } from '../../../test/frontend/features/project-list/fixtures/tags-data' -import { v4 as uuid } from 'uuid' - -const MOCK_DELAY = 500 - -export const Interactive = (args: any) => { - window.metaAttributesCache.set('ol-user_id', '624333f147cfd8002622a1d3') - useFetchMock(fetchMock => { - fetchMock.post( - /\/api\/project/, - { projects: projectsData, totalSize: projectsData.length }, - { delay: MOCK_DELAY } - ) - fetchMock.post( - 'express:/project/:projectId/clone', - () => ({ - project_id: uuid(), - name: copyableProject.name, - lastUpdated: new Date().toISOString(), - owner: { - _id: copyableProject.owner?.id, - email: copyableProject.owner?.id, - first_name: copyableProject.owner?.firstName, - last_name: copyableProject.owner?.lastName, - }, - }), - { delay: MOCK_DELAY } - ) - }) - - useMeta({ - 'ol-tags': tags, - }) - - return ( - - - - ) -} - -export default { - title: 'Project List / Project Table', - component: ProjectListTable, - decorators: [ - (Story: any) => ( -
- -
- ), - ], -} diff --git a/services/web/frontend/stories/project-list/project-search.stories.tsx b/services/web/frontend/stories/project-list/project-search.stories.tsx deleted file mode 100644 index cf39be2285..0000000000 --- a/services/web/frontend/stories/project-list/project-search.stories.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import SearchForm from '../../js/features/project-list/components/search-form' -import { ProjectListProvider } from '../../js/features/project-list/context/project-list-context' -import useFetchMock from '../hooks/use-fetch-mock' -import { projectsData } from '../../../test/frontend/features/project-list/fixtures/projects-data' - -export const Search = (args: any) => { - useFetchMock(fetchMock => { - fetchMock.post(/\/api\/project/, { - projects: projectsData, - totalSize: projectsData.length, - }) - }) - - return ( - - - - ) -} - -export default { - title: 'Project List / Project Search', - component: SearchForm, - args: { - inputValue: '', - }, -} diff --git a/services/web/frontend/stories/project-list/survey-widget-ds-nav.stories.tsx b/services/web/frontend/stories/project-list/survey-widget-ds-nav.stories.tsx deleted file mode 100644 index 8e9e0d1a40..0000000000 --- a/services/web/frontend/stories/project-list/survey-widget-ds-nav.stories.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { SurveyWidgetDsNav } from '@/features/project-list/components/survey-widget-ds-nav' - -export const Survey = (args: any) => { - localStorage.clear() - window.metaAttributesCache.set('ol-survey', { - name: 'my-survey', - title: 'To help shape the future of Overleaf', - text: 'Click here!', - cta: 'Let’s go!', - url: 'https://example.com/my-survey', - }) - - return -} - -export const UndefinedSurvey = (args: any) => { - localStorage.clear() - - return -} - -export const EmptySurvey = (args: any) => { - localStorage.clear() - window.metaAttributesCache.set('ol-survey', {}) - - return -} - -export default { - title: 'Project List / Survey Widget', - component: SurveyWidgetDsNav, -} diff --git a/services/web/frontend/stories/project-list/system-messages.stories.tsx b/services/web/frontend/stories/project-list/system-messages.stories.tsx deleted file mode 100644 index d97ca4537c..0000000000 --- a/services/web/frontend/stories/project-list/system-messages.stories.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import SystemMessages from '@/shared/components/system-messages' -import useFetchMock from '../hooks/use-fetch-mock' -import { type FetchMock } from 'fetch-mock' - -export const SystemMessage = (args: any) => { - useFetchMock((fetchMock: FetchMock) => { - fetchMock.get(/\/system\/messages/, [ - { - _id: 1, - content: ` - Closing this message will mark it as hidden. - Remove it from the local storage to make it appear again. - `, - }, - { - _id: 'protected', - content: 'A protected message content - cannot be closed', - }, - ]) - }) - - return -} - -export const TranslationMessage = (args: any) => { - useFetchMock((fetchMock: FetchMock) => { - fetchMock.get(/\/system\/messages/, []) - }) - - window.metaAttributesCache.set('ol-suggestedLanguage', { - url: '/dev/null', - lngName: 'German', - imgUrl: 'https://flagcdn.com/w40/de.png', - }) - - return -} - -export default { - title: 'Project List / System Messages', - component: SystemMessages, -} diff --git a/services/web/frontend/stories/settings/account-info.stories.jsx b/services/web/frontend/stories/settings/account-info.stories.jsx deleted file mode 100644 index 9375b8dacf..0000000000 --- a/services/web/frontend/stories/settings/account-info.stories.jsx +++ /dev/null @@ -1,58 +0,0 @@ -import useFetchMock from '../hooks/use-fetch-mock' -import AccountInfoSection from '../../js/features/settings/components/account-info-section' -import { setDefaultMeta, defaultSetupMocks } from './helpers/account-info' -import { UserProvider } from '../../js/shared/context/user-context' -import getMeta from '@/utils/meta' - -export const Success = args => { - setDefaultMeta() - useFetchMock(defaultSetupMocks) - - return ( - - - - ) -} - -export const ReadOnly = args => { - setDefaultMeta() - window.metaAttributesCache.set('ol-isExternalAuthenticationSystemUsed', true) - window.metaAttributesCache.set('ol-shouldAllowEditingDetails', false) - - return ( - - - - ) -} - -export const NoEmailInput = args => { - setDefaultMeta() - Object.assign(getMeta('ol-ExposedSettings'), { - hasAffiliationsFeature: true, - }) - useFetchMock(defaultSetupMocks) - - return ( - - - - ) -} - -export const Error = args => { - setDefaultMeta() - useFetchMock(fetchMock => fetchMock.post(/\/user\/settings/, 500)) - - return ( - - - - ) -} - -export default { - title: 'Account Settings / Account Info', - component: AccountInfoSection, -} diff --git a/services/web/frontend/stories/settings/add-email-input.stories.tsx b/services/web/frontend/stories/settings/add-email-input.stories.tsx deleted file mode 100644 index e62e370f03..0000000000 --- a/services/web/frontend/stories/settings/add-email-input.stories.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import useFetchMock from './../hooks/use-fetch-mock' -import Input from '../../js/features/settings/components/emails/add-email/input' - -export const EmailInput = (args: any) => { - useFetchMock(fetchMock => - fetchMock.get(/\/institutions\/domains/, [ - { - hostname: 'autocomplete.edu', - university: { id: 123, name: 'Auto Complete University' }, - }, - ]) - ) - return ( - <> - -
-
- Use autocomplete.edu as domain to trigger an autocomplete -
- - ) -} - -export default { - title: 'Account Settings / Emails and Affiliations', - component: Input, - argTypes: { - onChange: { action: 'change' }, - }, -} diff --git a/services/web/frontend/stories/settings/beta-program.stories.jsx b/services/web/frontend/stories/settings/beta-program.stories.jsx deleted file mode 100644 index 38f42cbd66..0000000000 --- a/services/web/frontend/stories/settings/beta-program.stories.jsx +++ /dev/null @@ -1,27 +0,0 @@ -import BetaProgramSection from '../../js/features/settings/components/beta-program-section' -import { UserProvider } from '../../js/shared/context/user-context' - -export const SectionNotEnrolled = args => { - window.metaAttributesCache.set('ol-user', { betaProgram: false }) - - return ( - - - - ) -} - -export const SectionEnrolled = args => { - window.metaAttributesCache.set('ol-user', { betaProgram: true }) - - return ( - - - - ) -} - -export default { - title: 'Account Settings / Beta program', - component: BetaProgramSection, -} diff --git a/services/web/frontend/stories/settings/emails.stories.jsx b/services/web/frontend/stories/settings/emails.stories.jsx deleted file mode 100644 index bccc1f0014..0000000000 --- a/services/web/frontend/stories/settings/emails.stories.jsx +++ /dev/null @@ -1,43 +0,0 @@ -import EmailsSection from '../../js/features/settings/components/emails-section' -import useFetchMock from './../hooks/use-fetch-mock' -import { - setDefaultMeta, - setReconfirmationMeta, - defaultSetupMocks, - reconfirmationSetupMocks, - errorsMocks, - emailLimitSetupMocks, -} from './helpers/emails' - -export const EmailsList = args => { - useFetchMock(defaultSetupMocks) - setDefaultMeta() - - return -} - -export const EmailLimitList = args => { - useFetchMock(emailLimitSetupMocks) - setDefaultMeta() - - return -} - -export const ReconfirmationEmailsList = args => { - useFetchMock(reconfirmationSetupMocks) - setReconfirmationMeta() - - return -} - -export const NetworkErrors = args => { - useFetchMock(errorsMocks) - setDefaultMeta() - - return -} - -export default { - title: 'Account Settings / Emails and Affiliations', - component: EmailsSection, -} diff --git a/services/web/frontend/stories/settings/helpers/account-info.js b/services/web/frontend/stories/settings/helpers/account-info.js deleted file mode 100644 index 47a65be772..0000000000 --- a/services/web/frontend/stories/settings/helpers/account-info.js +++ /dev/null @@ -1,23 +0,0 @@ -import getMeta from '@/utils/meta' - -const MOCK_DELAY = 1000 - -export function defaultSetupMocks(fetchMock) { - fetchMock.post(/\/user\/settings/, 200, { - delay: MOCK_DELAY, - }) -} - -export function setDefaultMeta() { - window.metaAttributesCache.set('ol-user', { - ...window.metaAttributesCache.get('ol-user'), - email: 'sherlock@holmes.co.uk', - first_name: 'Sherlock', - last_name: 'Holmes', - }) - Object.assign(getMeta('ol-ExposedSettings'), { - hasAffiliationsFeature: false, - }) - window.metaAttributesCache.set('ol-isExternalAuthenticationSystemUsed', false) - window.metaAttributesCache.set('ol-shouldAllowEditingDetails', true) -} diff --git a/services/web/frontend/stories/settings/helpers/emails.js b/services/web/frontend/stories/settings/helpers/emails.js deleted file mode 100644 index 5e7fe284e9..0000000000 --- a/services/web/frontend/stories/settings/helpers/emails.js +++ /dev/null @@ -1,215 +0,0 @@ -import getMeta from '@/utils/meta' - -const MOCK_DELAY = 1000 - -const fakeUsersData = [ - { - affiliation: { - institution: { - confirmed: true, - id: 1, - name: 'Overleaf', - }, - licence: 'pro_plus', - }, - confirmedAt: '2022-03-09T10:59:44.139Z', - email: 'foo@overleaf.com', - default: true, - emailHasInstitutionLicence: true, - }, - { - confirmedAt: '2022-03-10T10:59:44.139Z', - email: 'bar@overleaf.com', - default: false, - }, - { - affiliation: { - institution: { - confirmed: true, - id: 2, - name: 'Overleaf', - }, - licence: 'pro_plus', - department: 'Art & Art History', - role: 'Postdoc', - }, - email: 'baz@overleaf.com', - default: false, - }, - { - email: 'qux@overleaf.com', - default: false, - }, -] -const fakeReconfirmationUsersData = [ - { - affiliation: { - institution: { - confirmed: true, - isUniversity: true, - id: 4, - name: 'Reconfirmable Email Highlighted', - }, - licence: 'pro_plus', - inReconfirmNotificationPeriod: true, - }, - email: 'reconfirmation-highlighted@overleaf.com', - confirmedAt: '2022-03-09T10:59:44.139Z', - default: false, - }, - { - affiliation: { - institution: { - confirmed: true, - isUniversity: true, - id: 4, - name: 'Reconfirmable Emails Primary', - }, - licence: 'pro_plus', - inReconfirmNotificationPeriod: true, - }, - email: 'reconfirmation-nonsso@overleaf.com', - confirmedAt: '2022-03-09T10:59:44.139Z', - default: true, - }, - { - affiliation: { - institution: { - confirmed: true, - ssoEnabled: true, - isUniversity: true, - id: 3, - name: 'Reconfirmable SSO', - }, - licence: 'pro_plus', - inReconfirmNotificationPeriod: true, - }, - email: 'reconfirmation-sso@overleaf.com', - confirmedAt: '2022-03-09T10:59:44.139Z', - samlProviderId: 'reconfirmation-sso-provider-id', - default: false, - }, - { - affiliation: { - institution: { - confirmed: true, - isUniversity: true, - ssoEnabled: true, - id: 5, - name: 'Reconfirmed SSO', - }, - licence: 'pro_plus', - }, - confirmedAt: '2022-03-09T10:59:44.139Z', - email: 'sso@overleaf.com', - samlProviderId: 'sso-reconfirmed-provider-id', - default: false, - }, -] - -const fakeInstitutions = [ - { - id: 9326, - name: 'Unknown', - country_code: 'al', - departments: ['New department'], - }, -] -const fakeInstitution = { - id: 123, - name: 'test', - country_code: 'de', - departments: [], - team_id: null, -} -const bazFakeInstitution = { - id: 2, - name: 'Baz', - country_code: 'de', - departments: ['Custom department 1', 'Custom department 2'], - team_id: null, -} - -const fakeInstitutionDomain1 = [ - { - university: { - id: 1234, - ssoEnabled: true, - name: 'Auto Complete University', - }, - hostname: 'autocomplete.edu', - confirmed: true, - }, -] - -const fakeInstitutionDomain2 = [ - { - university: { - id: 5678, - ssoEnabled: false, - name: 'Fake Auto Complete University', - }, - hostname: 'fake-autocomplete.edu', - confirmed: true, - }, -] - -export function defaultSetupMocks(fetchMock) { - fetchMock - .get(/\/user\/emails/, fakeUsersData, { delay: MOCK_DELAY }) - .get(/\/institutions\/list\/2/, bazFakeInstitution, { delay: MOCK_DELAY }) - .get(/\/institutions\/list\/\d+/, fakeInstitution, { delay: MOCK_DELAY }) - .get(/\/institutions\/list\?country_code=.*/, fakeInstitutions, { - delay: MOCK_DELAY, - }) - .get(/\/institutions\/domains\?hostname=a/, fakeInstitutionDomain1) - .get(/\/institutions\/domains\?hostname=f/, fakeInstitutionDomain2) - .get(/\/institutions\/domains/, []) - .post(/\/user\/emails\/*/, 200, { - delay: MOCK_DELAY, - }) -} - -export function reconfirmationSetupMocks(fetchMock) { - defaultSetupMocks(fetchMock) - fetchMock.get(/\/user\/emails/, fakeReconfirmationUsersData, { - delay: MOCK_DELAY, - }) -} - -export function emailLimitSetupMocks(fetchMock) { - const userData = [] - for (let i = 0; i < 10; i++) { - userData.push({ email: `example${i}@overleaf.com` }) - } - defaultSetupMocks(fetchMock) - fetchMock.get(/\/user\/emails/, userData, { - delay: MOCK_DELAY, - }) -} - -export function errorsMocks(fetchMock) { - fetchMock - .get(/\/user\/emails/, fakeUsersData, { delay: MOCK_DELAY }) - .post(/\/user\/emails\/*/, 500) -} - -export function setDefaultMeta() { - Object.assign(getMeta('ol-ExposedSettings'), { - hasAffiliationsFeature: true, - hasSamlFeature: true, - samlInitPath: 'saml/init', - }) -} - -export function setReconfirmationMeta() { - setDefaultMeta() - window.metaAttributesCache.set( - 'ol-reconfirmationRemoveEmail', - 'reconfirmation-highlighted@overleaf.com' - ) - window.metaAttributesCache.set( - 'ol-reconfirmedViaSAML', - 'sso-reconfirmed-provider-id' - ) -} diff --git a/services/web/frontend/stories/settings/helpers/leave.js b/services/web/frontend/stories/settings/helpers/leave.js deleted file mode 100644 index 8b88b635ab..0000000000 --- a/services/web/frontend/stories/settings/helpers/leave.js +++ /dev/null @@ -1,17 +0,0 @@ -import getMeta from '@/utils/meta' - -const MOCK_DELAY = 1000 - -export function defaultSetupMocks(fetchMock) { - fetchMock.post(/\/user\/delete/, 200, { - delay: MOCK_DELAY, - }) -} - -export function setDefaultMeta() { - window.metaAttributesCache.set('ol-usersEmail', 'user@primary.com') - Object.assign(getMeta('ol-ExposedSettings'), { - isOverleaf: true, - }) - window.metaAttributesCache.set('ol-hasPassword', true) -} diff --git a/services/web/frontend/stories/settings/helpers/linking.js b/services/web/frontend/stories/settings/helpers/linking.js deleted file mode 100644 index de2733393f..0000000000 --- a/services/web/frontend/stories/settings/helpers/linking.js +++ /dev/null @@ -1,78 +0,0 @@ -const MOCK_DELAY = 1000 - -export function defaultSetupMocks(fetchMock) { - fetchMock - .post('/user/oauth-unlink', 200, { delay: MOCK_DELAY }) - .get( - 'express:/user/tpds/queues', - { tpdsToWeb: 0, webToTpds: 0 }, - { delay: MOCK_DELAY } - ) -} - -export function setDefaultMeta() { - window.metaAttributesCache.set('ol-user', { - ...window.metaAttributesCache.get('ol-user'), - features: { github: true, dropbox: true, mendeley: false, zotero: false }, - refProviders: { - mendeley: true, - zotero: true, - }, - }) - window.metaAttributesCache.set('ol-github', { enabled: false }) - window.metaAttributesCache.set('ol-dropbox', { registered: true }) - window.metaAttributesCache.set('ol-thirdPartyIds', { - collabratec: 'collabratec-id', - google: 'google-id', - }) - - window.metaAttributesCache.set('ol-oauthProviders', { - collabratec: { - descriptionKey: 'linked_collabratec_description', - descriptionOptions: { appName: 'Overleaf' }, - name: 'IEEE Collabratec®', - hideWhenNotLinked: true, - linkPath: '/collabratec/auth/link', - }, - google: { - descriptionKey: 'login_with_service', - descriptionOptions: { service: 'Google' }, - name: 'Google', - linkPath: '/auth/google', - }, - orcid: { - descriptionKey: 'oauth_orcid_description', - descriptionOptions: { - link: '/blog/434', - appName: 'Overleaf', - }, - name: 'ORCID', - linkPath: '/auth/orcid', - }, - }) - window.metaAttributesCache.set('ol-hideLinkingWidgets', true) - window.metaAttributesCache.delete('ol-ssoErrorMessage') -} - -export function setPersonalAccessTokensMeta() { - function generateToken(_id) { - const oneYearFromNow = new Date() - oneYearFromNow.setFullYear(oneYearFromNow.getFullYear() + 1) - - const tokenHasBeenUsed = Math.random() > 0.5 - - return { - _id, - accessTokenPartial: 'olp_abc' + _id, - createdAt: new Date(), - accessTokenExpiresAt: oneYearFromNow, - lastUsedAt: tokenHasBeenUsed ? new Date() : undefined, - } - } - const tokens = [] - for (let i = 0; i < 6; i++) { - tokens.push(generateToken(i)) - } - - window.metaAttributesCache.set('ol-personalAccessTokens', tokens) -} diff --git a/services/web/frontend/stories/settings/helpers/password.js b/services/web/frontend/stories/settings/helpers/password.js deleted file mode 100644 index d41dab1a02..0000000000 --- a/services/web/frontend/stories/settings/helpers/password.js +++ /dev/null @@ -1,35 +0,0 @@ -import getMeta from '@/utils/meta' - -const MOCK_DELAY = 1000 - -export function defaultSetupMocks(fetchMock) { - fetchMock.post( - /\/user\/password\/update/, - { - status: 200, - body: { - message: { - type: 'success', - email: 'tim.alby@overleaf.com', - text: 'Password changed', - }, - }, - }, - { - delay: MOCK_DELAY, - } - ) -} - -export function setDefaultMeta() { - Object.assign(getMeta('ol-ExposedSettings'), { - isOverleaf: true, - }) - window.metaAttributesCache.set('ol-isExternalAuthenticationSystemUsed', false) - window.metaAttributesCache.set('ol-hasPassword', true) - window.metaAttributesCache.set('ol-passwordStrengthOptions', { - length: { - min: 2, - }, - }) -} diff --git a/services/web/frontend/stories/settings/leave.stories.jsx b/services/web/frontend/stories/settings/leave.stories.jsx deleted file mode 100644 index b7ca02836c..0000000000 --- a/services/web/frontend/stories/settings/leave.stories.jsx +++ /dev/null @@ -1,71 +0,0 @@ -import useFetchMock from '../hooks/use-fetch-mock' -import LeaveModal from '../../js/features/settings/components/leave/modal' -import LeaveSection from '../../js/features/settings/components/leave-section' -import { setDefaultMeta, defaultSetupMocks } from './helpers/leave' - -export const Section = args => { - useFetchMock(defaultSetupMocks) - setDefaultMeta() - - return -} -Section.component = LeaveSection -Section.parameters = { controls: { include: [], hideNoControlsWarning: true } } - -export const ModalSuccess = args => { - setDefaultMeta() - useFetchMock(defaultSetupMocks) - - return -} - -export const ModalWithoutPassword = args => { - setDefaultMeta() - window.metaAttributesCache.set('ol-hasPassword', false) - useFetchMock(defaultSetupMocks) - - return -} - -export const ModalAuthError = args => { - setDefaultMeta() - useFetchMock(fetchMock => { - fetchMock.post(/\/user\/delete/, 403) - }) - - return -} - -export const ModalServerError = args => { - setDefaultMeta() - useFetchMock(fetchMock => { - fetchMock.post(/\/user\/delete/, 500) - }) - - return -} - -export const ModalSubscriptionError = args => { - setDefaultMeta() - useFetchMock(fetchMock => { - fetchMock.post(/\/user\/delete/, { - status: 422, - body: { - error: 'SubscriptionAdminDeletionError', - }, - }) - }) - - return -} - -export default { - title: 'Account Settings / Leave', - component: LeaveModal, - args: { - isOpen: true, - }, - argTypes: { - handleClose: { action: 'handleClose' }, - }, -} diff --git a/services/web/frontend/stories/settings/linking.stories.jsx b/services/web/frontend/stories/settings/linking.stories.jsx deleted file mode 100644 index 0799ce0bea..0000000000 --- a/services/web/frontend/stories/settings/linking.stories.jsx +++ /dev/null @@ -1,96 +0,0 @@ -import useFetchMock from '../hooks/use-fetch-mock' -import LinkingSection from '../../js/features/settings/components/linking-section' -import { setDefaultMeta, defaultSetupMocks } from './helpers/linking' -import { UserProvider } from '../../js/shared/context/user-context' -import { SSOProvider } from '../../js/features/settings/context/sso-context' -import { ScopeDecorator } from '../decorators/scope' -import { useEffect } from 'react' -import { useMeta } from '../hooks/use-meta' - -const MOCK_DELAY = 1000 - -export const Section = args => { - useFetchMock(defaultSetupMocks) - setDefaultMeta() - - return ( - - - - - - ) -} - -export const SectionAllUnlinked = args => { - useFetchMock(defaultSetupMocks) - - useMeta({ - 'ol-thirdPartyIds': {}, - 'ol-user': { - features: { github: true, dropbox: true, mendeley: true, zotero: true }, - refProviders: { - mendeley: false, - zotero: false, - }, - }, - 'ol-github': { enabled: false }, - 'ol-dropbox': { registered: false }, - }) - - useEffect(() => { - setDefaultMeta() - }, []) - - return ( - - - - - - ) -} - -export const SectionSSOErrors = args => { - useFetchMock(fetchMock => - fetchMock.post('/user/oauth-unlink', 500, { delay: MOCK_DELAY }) - ) - setDefaultMeta() - window.metaAttributesCache.set('ol-hideLinkingWidgets', true) - window.metaAttributesCache.set( - 'ol-ssoErrorMessage', - 'Account already linked to another Overleaf user' - ) - - return ( - - - - - - ) -} - -export const SectionProjetSyncSuccess = args => { - useFetchMock(defaultSetupMocks) - setDefaultMeta() - window.metaAttributesCache.set('ol-github', { enabled: true }) - window.metaAttributesCache.set( - 'ol-projectSyncSuccessMessage', - 'Thanks, we’ve successfully linked your GitHub account to Overleaf. You can now export your Overleaf projects to GitHub, or import projects from your GitHub repositories.' - ) - - return ( - - - - - - ) -} - -export default { - title: 'Account Settings / Linking', - component: LinkingSection, - decorators: [ScopeDecorator], -} diff --git a/services/web/frontend/stories/settings/newsletter.stories.jsx b/services/web/frontend/stories/settings/newsletter.stories.jsx deleted file mode 100644 index f0fffb6a35..0000000000 --- a/services/web/frontend/stories/settings/newsletter.stories.jsx +++ /dev/null @@ -1,10 +0,0 @@ -import NewsletterSection from '../../js/features/settings/components/newsletter-section' - -export const Section = args => { - return -} - -export default { - title: 'Account Settings / Newsletter', - component: NewsletterSection, -} diff --git a/services/web/frontend/stories/settings/page.stories.jsx b/services/web/frontend/stories/settings/page.stories.jsx deleted file mode 100644 index 53a4830430..0000000000 --- a/services/web/frontend/stories/settings/page.stories.jsx +++ /dev/null @@ -1,81 +0,0 @@ -import useFetchMock from '../hooks/use-fetch-mock' -import SettingsPageRoot from '../../js/features/settings/components/root' -import { - setDefaultMeta as setDefaultLeaveMeta, - defaultSetupMocks as defaultSetupLeaveMocks, -} from './helpers/leave' -import { - setDefaultMeta as setDefaultAccountInfoMeta, - defaultSetupMocks as defaultSetupAccountInfoMocks, -} from './helpers/account-info' -import { - setDefaultMeta as setDefaultPasswordMeta, - defaultSetupMocks as defaultSetupPasswordMocks, -} from './helpers/password' -import { - setDefaultMeta as setDefaultEmailsMeta, - defaultSetupMocks as defaultSetupEmailsMocks, -} from './helpers/emails' -import { - setDefaultMeta as setDefaultLinkingMeta, - defaultSetupMocks as defaultSetupLinkingMocks, - setPersonalAccessTokensMeta, -} from './helpers/linking' -import { UserProvider } from '../../js/shared/context/user-context' -import { ScopeDecorator } from '../decorators/scope' -import getMeta from '@/utils/meta' - -export const Overleaf = args => { - setDefaultLeaveMeta() - setDefaultAccountInfoMeta() - setDefaultPasswordMeta() - setDefaultEmailsMeta() - setDefaultLinkingMeta() - useFetchMock(fetchMock => { - defaultSetupLeaveMocks(fetchMock) - defaultSetupAccountInfoMocks(fetchMock) - defaultSetupPasswordMocks(fetchMock) - defaultSetupEmailsMocks(fetchMock) - defaultSetupLinkingMocks(fetchMock) - }) - - return ( - - - - ) -} - -export const OverleafWithAccessTokens = args => { - setPersonalAccessTokensMeta() - return Overleaf(args) -} - -export const ServerPro = args => { - setDefaultAccountInfoMeta() - setDefaultPasswordMeta() - setPersonalAccessTokensMeta() - useFetchMock(fetchMock => { - defaultSetupAccountInfoMocks(fetchMock) - defaultSetupPasswordMocks(fetchMock) - }) - - Object.assign(getMeta('ol-ExposedSettings'), { - hasAffiliationsFeature: false, - isOverleaf: false, - }) - window.metaAttributesCache.set('ol-hideLinkingWidgets', true) - window.metaAttributesCache.delete('ol-oauthProviders') - - return ( - - - - ) -} - -export default { - title: 'Account Settings / Full Page', - component: SettingsPageRoot, - decorators: [ScopeDecorator], -} diff --git a/services/web/frontend/stories/settings/password.stories.jsx b/services/web/frontend/stories/settings/password.stories.jsx deleted file mode 100644 index 46811176f5..0000000000 --- a/services/web/frontend/stories/settings/password.stories.jsx +++ /dev/null @@ -1,49 +0,0 @@ -import useFetchMock from '../hooks/use-fetch-mock' -import PasswordSection from '../../js/features/settings/components/password-section' -import { setDefaultMeta, defaultSetupMocks } from './helpers/password' -import getMeta from '@/utils/meta' - -export const Success = args => { - setDefaultMeta() - useFetchMock(defaultSetupMocks) - - return -} - -export const ManagedExternally = args => { - setDefaultMeta() - Object.assign(getMeta('ol-ExposedSettings'), { - isOverleaf: false, - }) - window.metaAttributesCache.set('ol-isExternalAuthenticationSystemUsed', true) - useFetchMock(defaultSetupMocks) - - return -} - -export const NoExistingPassword = args => { - setDefaultMeta() - window.metaAttributesCache.set('ol-hasPassword', false) - useFetchMock(defaultSetupMocks) - - return -} - -export const Error = args => { - setDefaultMeta() - useFetchMock(fetchMock => - fetchMock.post(/\/user\/password\/update/, { - status: 400, - body: { - message: 'Your old password is wrong', - }, - }) - ) - - return -} - -export default { - title: 'Account Settings / Password', - component: PasswordSection, -} diff --git a/services/web/frontend/stories/settings/sessions.stories.jsx b/services/web/frontend/stories/settings/sessions.stories.jsx deleted file mode 100644 index 668ed08b6a..0000000000 --- a/services/web/frontend/stories/settings/sessions.stories.jsx +++ /dev/null @@ -1,10 +0,0 @@ -import SessionsSection from '../../js/features/settings/components/sessions-section' - -export const Section = args => { - return -} - -export default { - title: 'Account Settings / Sessions', - component: SessionsSection, -} diff --git a/services/web/frontend/stories/settings/sso-alert.stories.tsx b/services/web/frontend/stories/settings/sso-alert.stories.tsx deleted file mode 100644 index e07bdb0dcc..0000000000 --- a/services/web/frontend/stories/settings/sso-alert.stories.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import EmailsSection from '../../js/features/settings/components/emails-section' -import { SSOAlert } from '../../js/features/settings/components/emails/sso-alert' - -export const Info = () => { - window.metaAttributesCache.set('ol-institutionLinked', { - universityName: 'Overleaf University', - }) - return -} - -export const InfoWithEntitlement = () => { - window.metaAttributesCache.set('ol-institutionLinked', { - universityName: 'Overleaf University', - hasEntitlement: true, - }) - return -} - -export const NonCanonicalEmail = () => { - window.metaAttributesCache.set('ol-institutionLinked', { - universityName: 'Overleaf University', - }) - window.metaAttributesCache.set( - 'ol-institutionEmailNonCanonical', - 'user@example.com' - ) - return -} - -export const Error = () => { - window.metaAttributesCache.set('ol-samlError', { - translatedMessage: 'There was an Error', - }) - return -} - -export const ErrorTranslated = () => { - window.metaAttributesCache.set('ol-samlError', { - translatedMessage: 'Translated Error Message', - message: 'There was an Error', - }) - return -} - -export const ErrorWithTryAgain = () => { - window.metaAttributesCache.set('ol-samlError', { - message: 'There was an Error', - tryAgain: true, - }) - return -} - -export default { - title: 'Account Settings / SSO Alerts', - component: EmailsSection, -} diff --git a/services/web/frontend/stories/share-project-modal.stories.jsx b/services/web/frontend/stories/share-project-modal.stories.jsx deleted file mode 100644 index 5c6d3949f0..0000000000 --- a/services/web/frontend/stories/share-project-modal.stories.jsx +++ /dev/null @@ -1,182 +0,0 @@ -import ShareProjectModal from '../js/features/share-project-modal/components/share-project-modal' -import useFetchMock from './hooks/use-fetch-mock' -import { useScope } from './hooks/use-scope' -import { ScopeDecorator } from './decorators/scope' -import { contacts } from './fixtures/contacts' -import { project } from './fixtures/project' - -export const LinkSharingOff = args => { - useFetchMock(setupFetchMock) - - useScope({ - project: { - ...args.project, - publicAccesLevel: 'private', - }, - }) - - return -} - -export const LinkSharingOn = args => { - useFetchMock(setupFetchMock) - - useScope({ - project: { - ...args.project, - publicAccesLevel: 'tokenBased', - }, - }) - - return -} - -export const LinkSharingLoading = args => { - useFetchMock(setupFetchMock) - - useScope({ - project: { - ...args.project, - publicAccesLevel: 'tokenBased', - }, - }) - - return -} - -export const NonProjectOwnerLinkSharingOff = args => { - useScope({ - project: { - ...args.project, - publicAccesLevel: 'private', - }, - }) - - return -} - -export const NonProjectOwnerLinkSharingOn = args => { - useScope({ - project: { - ...args.project, - publicAccesLevel: 'tokenBased', - }, - }) - - return -} - -export const RestrictedTokenMember = args => { - // Override isRestrictedTokenMember to be true - // Currently this is necessary because the context value is set from window, - // however in the future we should change this to set via props - window.metaAttributesCache.set('ol-isRestrictedTokenMember', true) - - useScope({ - project: { - ...args.project, - publicAccesLevel: 'tokenBased', - }, - }) - - return -} - -export const LegacyLinkSharingReadAndWrite = args => { - useFetchMock(setupFetchMock) - - useScope({ - project: { - ...args.project, - publicAccesLevel: 'readAndWrite', - }, - }) - - return -} - -export const LegacyLinkSharingReadOnly = args => { - useFetchMock(setupFetchMock) - - useScope({ - project: { - ...args.project, - publicAccesLevel: 'readOnly', - }, - }) - - return -} - -export const LimitedCollaborators = args => { - useFetchMock(setupFetchMock) - - useScope({ - project: { - ...args.project, - features: { - ...args.project.features, - collaborators: 3, - }, - }, - }) - - return -} - -export default { - title: 'Editor / Modals / Share Project', - component: ShareProjectModal, - args: { - show: true, - animation: false, - user: {}, - project: { - ...project, - owner: { - ...project.owner, - _id: 'story-user', - }, - }, - }, - argTypes: { - handleHide: { action: 'hide' }, - }, - decorators: [ScopeDecorator], -} - -function setupFetchMock(fetchMock) { - const delay = 1000 - - fetchMock - // list contacts - .get('express:/user/contacts', { contacts }, { delay }) - // access tokens - .get( - 'express:/project/:projectId/tokens', - { tokens: project.tokens }, - { delay } - ) - // change privacy setting - .post('express:/project/:projectId/settings/admin', 200, { delay }) - // update project member (e.g. set privilege level) - .put('express:/project/:projectId/users/:userId', 200, { delay }) - // remove project member - .delete('express:/project/:projectId/users/:userId', 200, { delay }) - // transfer ownership - .post('express:/project/:projectId/transfer-ownership', 200, { - delay, - }) - // send invite - .post('express:/project/:projectId/invite', 200, { delay }) - // delete invite - .delete('express:/project/:projectId/invite/:inviteId', 204, { - delay, - }) - // resend invite - .post('express:/project/:projectId/invite/:inviteId/resend', 200, { - delay, - }) - // send analytics event - .post('express:/event/:key', 200) -} diff --git a/services/web/frontend/stories/shared/autocomplete.stories.tsx b/services/web/frontend/stories/shared/autocomplete.stories.tsx deleted file mode 100644 index 1b63735cb3..0000000000 --- a/services/web/frontend/stories/shared/autocomplete.stories.tsx +++ /dev/null @@ -1,336 +0,0 @@ -import { Meta, StoryObj } from '@storybook/react-webpack5' -import { useState } from 'react' -import OLAutocomplete from '@/shared/components/ol/ol-autocomplete' - -type Args = React.ComponentProps - -const sampleItems = [ - { value: 'react', label: 'React' }, - { value: 'vue', label: 'Vue' }, - { value: 'angular', label: 'Angular' }, - { value: 'svelte', label: 'Svelte' }, - { value: 'ember', label: 'Ember' }, - { value: 'backbone', label: 'Backbone' }, - { value: 'preact', label: 'Preact' }, - { value: 'nextjs', label: 'Next.js' }, - { value: 'nuxtjs', label: 'Nuxt.js' }, - { value: 'gatsby', label: 'Gatsby' }, -] - -const programmingLanguages = [ - { value: 'javascript', label: 'JavaScript' }, - { value: 'typescript', label: 'TypeScript' }, - { value: 'python', label: 'Python' }, - { value: 'java', label: 'Java' }, - { value: 'cpp', label: 'C++' }, - { value: 'csharp', label: 'C#' }, - { value: 'go', label: 'Go' }, - { value: 'rust', label: 'Rust' }, - { value: 'ruby', label: 'Ruby' }, - { value: 'php', label: 'PHP' }, - { value: 'swift', label: 'Swift' }, - { value: 'kotlin', label: 'Kotlin' }, - { value: 'dart', label: 'Dart' }, -] - -const countries = [ - { value: 'us', label: 'United States' }, - { value: 'uk', label: 'United Kingdom' }, - { value: 'ca', label: 'Canada' }, - { value: 'au', label: 'Australia' }, - { value: 'de', label: 'Germany' }, - { value: 'fr', label: 'France' }, - { value: 'es', label: 'Spain' }, - { value: 'it', label: 'Italy' }, - { value: 'nl', label: 'Netherlands' }, - { value: 'be', label: 'Belgium' }, -] - -const entryTypeItems = [ - { value: 'article', label: 'Article', group: 'Most common' }, - { value: 'review', label: 'Review', group: 'Most common' }, - { value: 'book', label: 'Book', group: 'Most common' }, - { value: 'conference', label: 'Conference paper', group: 'Most common' }, - { value: 'thesis', label: 'Thesis', group: 'Most common' }, - { value: 'mastersthesis', label: "Master's thesis", group: 'Most common' }, - { value: 'phdthesis', label: 'PhD thesis', group: 'Most common' }, - { value: 'bookchapter', label: 'Book chapter', group: 'Book types' }, - { value: 'booksection', label: 'Book section', group: 'Book types' }, - { value: 'bookpart', label: 'Book part', group: 'Book types' }, - { value: 'editedbook', label: 'Edited book', group: 'Book types' }, - { value: 'referencebook', label: 'Reference book', group: 'Book types' }, - { value: 'proceedings', label: 'Conference proceedings', group: 'Other' }, - { value: 'journalarticle', label: 'Journal article', group: 'Other' }, - { value: 'techreport', label: 'Technical report', group: 'Other' }, - { value: 'preprint', label: 'Preprint', group: 'Other' }, - { value: 'patent', label: 'Patent', group: 'Other' }, - { value: 'manuscript', label: 'Manuscript', group: 'Other' }, - { value: 'unpublished', label: 'Unpublished', group: 'Other' }, -] - -function InteractiveAutocomplete(args: Args) { - const [value, setValue] = useState('') - - return ( -
- -
- Current value: {value || '(empty)'} -
-
- ) -} - -export const Default: StoryObj = { - render: args => , - args: { - items: sampleItems, - label: 'Select a framework', - placeholder: 'Type to search...', - showLabel: true, - allowCreate: true, - }, -} - -export const WithoutCreateOption: StoryObj = { - render: args => , - args: { - items: programmingLanguages, - label: 'Programming language', - placeholder: 'Choose a language', - showLabel: true, - allowCreate: false, - }, -} - -export const AllowCreatePredicate: StoryObj = { - render: args => , - args: { - items: programmingLanguages, - label: 'Programming language (custom rule)', - placeholder: 'Try typing java or script', - showLabel: true, - allowCreate: value => { - const normalized = value.trim().toLowerCase() - return ( - normalized.length >= 4 && - !normalized.includes('script') && - normalized !== 'java' - ) - }, - }, -} - -export const HiddenLabel: StoryObj = { - render: args => , - args: { - items: countries, - label: 'Country', - placeholder: 'Select your country', - showLabel: false, - allowCreate: true, - }, -} - -export const CustomCreatePrefix: StoryObj = { - render: args => , - args: { - items: [ - { value: 'alpha', label: 'Project Alpha' }, - { value: 'beta', label: 'Project Beta' }, - { value: 'gamma', label: 'Project Gamma' }, - ], - label: 'Project name', - placeholder: 'Select or create a project', - showLabel: true, - allowCreate: true, - createOptionPrefix: '➕ Create new project:', - }, -} - -export const Disabled: StoryObj = { - render: args => , - args: { - items: sampleItems, - label: 'Framework (disabled)', - placeholder: 'Cannot interact', - showLabel: true, - disabled: true, - }, -} - -export const SmallList: StoryObj = { - render: args => , - args: { - items: [ - { value: 'option1', label: 'Option 1' }, - { value: 'option2', label: 'Option 2' }, - { value: 'option3', label: 'Option 3' }, - ], - label: 'Choose an option', - placeholder: 'Type or select...', - showLabel: true, - allowCreate: true, - }, -} - -export const GroupedItems: StoryObj = { - render: args => , - args: { - items: entryTypeItems, - label: 'Entry type', - placeholder: 'Enter entry type', - showLabel: true, - allowCreate: true, - }, -} - -export const GroupedWithoutCreate: StoryObj = { - render: args => , - args: { - items: [ - { value: 'react', label: 'React', group: 'Frontend' }, - { value: 'vue', label: 'Vue', group: 'Frontend' }, - { value: 'angular', label: 'Angular', group: 'Frontend' }, - { value: 'svelte', label: 'Svelte', group: 'Frontend' }, - { value: 'nodejs', label: 'Node.js', group: 'Backend' }, - { value: 'django', label: 'Django', group: 'Backend' }, - { value: 'rails', label: 'Ruby on Rails', group: 'Backend' }, - { value: 'spring', label: 'Spring Boot', group: 'Backend' }, - { value: 'reactnative', label: 'React Native', group: 'Mobile' }, - { value: 'flutter', label: 'Flutter', group: 'Mobile' }, - { value: 'swift', label: 'Swift', group: 'Mobile' }, - { value: 'kotlin', label: 'Kotlin', group: 'Mobile' }, - ], - label: 'Technology', - placeholder: 'Search technologies...', - showLabel: true, - allowCreate: false, - }, -} - -export const FuzzySearch: StoryObj = { - render: args => , - args: { - items: [ - { value: 'javascript', label: 'JavaScript' }, - { value: 'typescript', label: 'TypeScript' }, - { value: 'python', label: 'Python' }, - { value: 'rust', label: 'Rust' }, - { value: 'golang', label: 'Go' }, - ], - label: 'Language (fuzzy search)', - placeholder: 'Try typo searches, e.g. javasript or pyton', - showLabel: true, - allowCreate: true, - useFuzzySearch: true, - }, -} - -export const GroupedFuzzySearch: StoryObj = { - render: args => , - args: { - items: entryTypeItems, - label: 'Grouped entry type (fuzzy search)', - placeholder: 'Try typo searches, e.g. confernce or theis', - showLabel: true, - allowCreate: true, - useFuzzySearch: true, - }, -} - -export const ExpandUp: StoryObj = { - render: args => , - args: { - items: sampleItems, - label: 'Framework (expand up)', - placeholder: 'Dropdown expands upward', - showLabel: true, - allowCreate: true, - expandUp: true, - }, -} - -export const ExpandUpGrouped: StoryObj = { - render: args => , - args: { - items: entryTypeItems, - label: 'Entry type (expand up)', - placeholder: 'Grouped items expanding upward', - showLabel: true, - allowCreate: true, - expandUp: true, - }, -} - -const meta: Meta = { - title: 'Shared / Components / Autocomplete', - component: OLAutocomplete, - tags: ['autodocs'], - parameters: { - controls: { - include: [ - 'items', - 'label', - 'placeholder', - 'showLabel', - 'allowCreate', - 'disabled', - 'createOptionPrefix', - 'useFuzzySearch', - 'expandUp', - ], - }, - }, - argTypes: { - items: { - control: 'object', - description: - 'Array of available options; use the optional group property for grouped sections', - }, - onChange: { - description: 'Callback when value changes', - }, - label: { - control: 'text', - description: 'Label for the input', - }, - placeholder: { - control: 'text', - description: 'Placeholder text', - }, - showLabel: { - control: 'boolean', - description: 'Show or hide the label visually', - }, - allowCreate: { - control: 'boolean', - description: - 'Allow creating custom values, or provide a predicate function `(value) => boolean` to conditionally allow create per input value', - table: { - type: { - summary: 'boolean | ((value: string) => boolean)', - }, - }, - }, - disabled: { - control: 'boolean', - description: 'Disable the input', - }, - createOptionPrefix: { - control: 'text', - description: 'Text prefix for the create option', - }, - useFuzzySearch: { - control: 'boolean', - description: 'Enable fuzzy search matching for suggestions', - }, - expandUp: { - control: 'boolean', - description: - 'When true, the dropdown expands upward and the search bar appears below the results list', - }, - }, -} - -export default meta diff --git a/services/web/frontend/stories/shared/badge.stories.tsx b/services/web/frontend/stories/shared/badge.stories.tsx deleted file mode 100644 index 25098e0ad3..0000000000 --- a/services/web/frontend/stories/shared/badge.stories.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react-webpack5' -import classnames from 'classnames' -import { figmaDesignUrl } from '../../../.storybook/utils/figma-design-url' -import Badge from '@/shared/components/badge/badge' -import MaterialIcon from '@/shared/components/material-icon' - -const meta: Meta = { - title: 'Shared / Components / Badge', - component: Badge, - args: { - children: 'Badge', - }, - argTypes: { - bg: { - options: ['light', 'info', 'primary', 'warning', 'danger'], - control: { type: 'radio' }, - }, - prepend: { - table: { - disable: true, - }, - }, - className: { - table: { - disable: true, - }, - }, - }, -} -export default meta - -type Story = StoryObj - -export const BadgeDefault: Story = { - args: { - bg: meta.argTypes!.bg!.options![0], - }, - parameters: figmaDesignUrl( - 'https://www.figma.com/design/V7Ogph1Ocs4ux2A4WMNAh7/Overleaf---Components?node-id=3458-9502&m=dev' - ), - render: args => ( - - ), -} - -export const BadgePrepend: Story = { - args: { - bg: meta.argTypes!.bg!.options![0], - }, - parameters: figmaDesignUrl( - 'https://www.figma.com/design/V7Ogph1Ocs4ux2A4WMNAh7/Overleaf---Components?node-id=3458-11319&m=dev' - ), - render: args => { - return ( - } - {...args} - /> - ) - }, -} diff --git a/services/web/frontend/stories/shared/billing-period-toggle.stories.tsx b/services/web/frontend/stories/shared/billing-period-toggle.stories.tsx deleted file mode 100644 index 3dfc48dc74..0000000000 --- a/services/web/frontend/stories/shared/billing-period-toggle.stories.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react-webpack5' -import { useState } from 'react' -import BillingPeriodToggle, { - type BillingPeriod, -} from '../../js/shared/components/billing-period-toggle' - -type Args = React.ComponentProps - -const meta: Meta = { - title: 'Subscription / Billing Period Toggle', - component: BillingPeriodToggle, - parameters: { - controls: { - include: ['value', 'showDiscount', 'variant'], - }, - }, - argTypes: { - value: { - control: 'radio', - options: ['monthly', 'annual'], - }, - showDiscount: { - control: 'boolean', - }, - variant: { - control: 'radio', - options: ['default', 'premium'], - }, - onChange: { action: 'onChange' }, - }, - args: { - value: 'monthly', - showDiscount: true, - variant: 'default', - }, -} - -export default meta - -type Story = StoryObj - -const InteractiveToggle = (args: Args) => { - const [value, setValue] = useState(args.value) - return ( - { - setValue(period) - args.onChange?.(period) - }} - /> - ) -} - -export const Default: Story = { - render: InteractiveToggle, -} - -export const WithoutDiscount: Story = { - render: args => , -} - -export const AnnualSelected: Story = { - args: { - value: 'annual', - }, - render: InteractiveToggle, -} - -export const PremiumVariant: Story = { - args: { - variant: 'premium', - }, - render: InteractiveToggle, -} - -export const PremiumVariantAnnualSelected: Story = { - args: { - variant: 'premium', - value: 'annual', - }, - render: InteractiveToggle, -} diff --git a/services/web/frontend/stories/shared/button.stories.tsx b/services/web/frontend/stories/shared/button.stories.tsx deleted file mode 100644 index 63bfda2af5..0000000000 --- a/services/web/frontend/stories/shared/button.stories.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { Meta } from '@storybook/react-webpack5' -import { figmaDesignUrl } from '../../../.storybook/utils/figma-design-url' -import OLButton from '@/shared/components/ol/ol-button' - -type Args = React.ComponentProps - -export const NewButton = (args: Args) => { - return -} - -export const ButtonWithLeadingIcon = (args: Args) => { - return -} - -export const ButtonWithTrailingIcon = (args: Args) => { - return -} - -export const ButtonWithIcons = (args: Args) => { - return -} - -const meta: Meta = { - title: 'Shared / Components / Button', - component: OLButton, - args: { - children: 'Button', - disabled: false, - isLoading: false, - }, - argTypes: { - size: { - control: 'radio', - options: ['lg', 'md', 'sm'], - }, - variant: { - control: 'radio', - options: [ - 'primary', - 'secondary', - 'ghost', - 'danger', - 'danger-ghost', - 'premium', - 'premium-secondary', - 'link', - ], - }, - }, - parameters: { - controls: { - include: ['children', 'disabled', 'isLoading', 'size', 'variant'], - }, - ...figmaDesignUrl( - 'https://www.figma.com/design/V7Ogph1Ocs4ux2A4WMNAh7/Overleaf---Components?node-id=3458-22412&m=dev' - ), - }, -} - -export default meta diff --git a/services/web/frontend/stories/shared/buttons.stories.tsx b/services/web/frontend/stories/shared/buttons.stories.tsx deleted file mode 100644 index 2ae8824222..0000000000 --- a/services/web/frontend/stories/shared/buttons.stories.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { Meta, StoryObj } from '@storybook/react-webpack5' -import OLButton from '@/shared/components/ol/ol-button' -import { ButtonProps } from '@/shared/components/types/button-props' - -export default { - title: 'Shared / Components / Button', - component: OLButton, -} satisfies Meta - -type Story = StoryObj - -const variants: ButtonProps['variant'][] = [ - 'primary', - 'secondary', - 'ghost', - 'danger', - 'danger-ghost', - 'premium', - 'premium-secondary', - 'link', -] - -export const Variants: Story = { - render() { - return ( - <> -
- {variants.map(variant => ( -
- Button - - Button - - - Button - - - Button - -
- ))} -
-

Dark Mode

-
-
- {variants.map(variant => ( -
- Button - - Button - - - Button - - - Button - -
- ))} -
- - ) - }, -} diff --git a/services/web/frontend/stories/shared/dropdown-menu.stories.tsx b/services/web/frontend/stories/shared/dropdown-menu.stories.tsx deleted file mode 100644 index 9d0e7e52f0..0000000000 --- a/services/web/frontend/stories/shared/dropdown-menu.stories.tsx +++ /dev/null @@ -1,236 +0,0 @@ -import { - DropdownMenu, - DropdownItem, - DropdownDivider, - DropdownHeader, -} from '@/shared/components/dropdown/dropdown-menu' -import type { Meta } from '@storybook/react-webpack5' -import OLDropdownMenuItem from '@/shared/components/ol/ol-dropdown-menu-item' -import { IdeRedesign } from '../decorators/ide-redesign' - -type Args = React.ComponentProps - -export const Default = (args: Args) => { - return ( - -
  • - - Example - -
  • -
  • - - Example - -
  • - -
  • - - Example - -
  • -
    - ) -} - -export const Active = (args: Args) => { - return ( - -
  • - - Example - -
  • -
  • - - Example - -
  • - -
  • - - Example - -
  • -
    - ) -} - -export const MultipleSelection = () => { - return ( - - Header -
  • - } - > - Example - -
  • -
  • - - Example - -
  • -
  • - - Example - -
  • -
    - ) -} - -export const Danger = (args: Args) => { - return ( - -
  • - - Example - -
  • -
  • - - Example - -
  • - -
  • - - Example - -
  • -
    - ) -} - -export const Description = (args: Args) => { - return ( - -
  • - - Example - -
  • -
  • - - Example - -
  • -
    - ) -} - -export const LeadingIcon = (args: Args) => { - return ( - - - Editor & PDF - - - Editor only - - - PDF only - - - PDF in separate tab - - - With a description - - - Small icon - - - ) -} - -export const TrailingIcon = () => { - return ( - - - Tick - - - With a description - - - With a leading icon - - - ) -} - -const meta: Meta = { - title: 'Shared / Components / DropdownMenu', - component: DropdownMenu, - args: { - disabled: false, - }, - parameters: { - controls: { - include: ['disabled'], - }, - }, - decorators: [IdeRedesign], -} - -export default meta diff --git a/services/web/frontend/stories/shared/ds/ds-button.stories.tsx b/services/web/frontend/stories/shared/ds/ds-button.stories.tsx deleted file mode 100644 index a5a40e22c1..0000000000 --- a/services/web/frontend/stories/shared/ds/ds-button.stories.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Meta } from '@storybook/react-webpack5' -import { figmaDesignUrl } from '../../../../.storybook/utils/figma-design-url' -import DSButton from '@/shared/components/ds/ds-button' - -type Args = React.ComponentProps - -export const Button = (args: Args) => { - return -} - -const meta: Meta = { - title: 'Shared / DS Components', - component: DSButton, - args: { - children: 'Button', - }, - argTypes: { - size: { - control: 'radio', - options: ['lg', 'md', 'sm'], - }, - variant: { - control: 'radio', - options: ['primary', 'secondary', 'tertiary', 'danger'], - }, - }, - parameters: { - controls: { - include: ['children', 'disabled', 'isLoading', 'size', 'variant'], - }, - ...figmaDesignUrl( - 'https://www.figma.com/design/aJQlecvqCS9Ry8b6JA1lQN/DS---Components?node-id=4565-2932&m=dev' - ), - }, -} - -export default meta diff --git a/services/web/frontend/stories/shared/ds/ds-form-check.stories.tsx b/services/web/frontend/stories/shared/ds/ds-form-check.stories.tsx deleted file mode 100644 index 5fa5a15146..0000000000 --- a/services/web/frontend/stories/shared/ds/ds-form-check.stories.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react-webpack5' -import DSFormCheckbox from '@/shared/components/ds/ds-form-checkbox' -import { figmaDesignUrl } from '../../../../.storybook/utils/figma-design-url' - -const meta: Meta = { - title: 'Shared / DS Components / Form', - component: DSFormCheckbox, - parameters: { - controls: { - include: ['disabled'], - }, - }, -} -export default meta - -type Story = StoryObj - -export const Checkbox: Story = { - args: { - id: 'id-1', - label: 'Label', - disabled: false, - }, - parameters: figmaDesignUrl( - 'https://www.figma.com/design/aJQlecvqCS9Ry8b6JA1lQN/DS---Components?node-id=6135-5430&m=dev' - ), -} diff --git a/services/web/frontend/stories/shared/ds/ds-form-control.stories.tsx b/services/web/frontend/stories/shared/ds/ds-form-control.stories.tsx deleted file mode 100644 index b3b3096beb..0000000000 --- a/services/web/frontend/stories/shared/ds/ds-form-control.stories.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { Meta } from '@storybook/react-webpack5' -import { figmaDesignUrl } from '../../../../.storybook/utils/figma-design-url' -import DSFormControl from '@/shared/components/ds/ds-form-control' -import DSFormText from '@/shared/components/ds/ds-form-text' -import DSFormGroup from '@/shared/components/ds/ds-form-group' -import DSFormLabel from '@/shared/components/ds/ds-form-label' -import { ComponentProps } from 'react' - -type Args = ComponentProps & { - textType: ComponentProps['type'] -} - -export const FormControl = ({ textType, value, ...args }: Args) => { - return ( - - Form input label - - Form input feedback - - ) -} - -const meta: Meta = { - title: 'Shared / DS Components / Form', - component: FormControl, - argTypes: { - disabled: { control: 'boolean' }, - isInvalid: { control: 'boolean' }, - placeholder: { control: 'text' }, - readOnly: { control: 'boolean' }, - value: { control: 'text' }, - size: { control: 'radio', options: ['lg', undefined] }, - textType: { control: 'radio', options: ['error', 'success', undefined] }, - }, - parameters: { - controls: { - include: [ - 'disabled', - 'isInvalid', - 'placeholder', - 'readOnly', - 'size', - 'value', - 'textType', - ], - }, - ...figmaDesignUrl( - 'https://www.figma.com/design/aJQlecvqCS9Ry8b6JA1lQN/DS---Components?node-id=6318-428&t=pcx9KKzhlzpRmA4S-0' - ), - }, -} - -export default meta diff --git a/services/web/frontend/stories/shared/ds/ds-form-radio.stories.tsx b/services/web/frontend/stories/shared/ds/ds-form-radio.stories.tsx deleted file mode 100644 index 99a65cacf1..0000000000 --- a/services/web/frontend/stories/shared/ds/ds-form-radio.stories.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react-webpack5' -import DSFormRadio from '@/shared/components/ds/ds-form-radio' -import { figmaDesignUrl } from '../../../../.storybook/utils/figma-design-url' - -const meta: Meta = { - title: 'Shared / DS Components / Form', - component: DSFormRadio, - parameters: { - controls: { - include: ['disabled'], - }, - }, -} -export default meta - -type Story = StoryObj - -export const Radio: Story = { - args: { - id: 'id-1', - label: 'Label', - disabled: false, - }, - parameters: figmaDesignUrl( - 'https://www.figma.com/design/aJQlecvqCS9Ry8b6JA1lQN/DS---Components?node-id=6135-5510&m=dev' - ), -} diff --git a/services/web/frontend/stories/shared/ds/ds-notification.stories.tsx b/services/web/frontend/stories/shared/ds/ds-notification.stories.tsx deleted file mode 100644 index 98b13c8c41..0000000000 --- a/services/web/frontend/stories/shared/ds/ds-notification.stories.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import DSNotification from '@/shared/components/ds/ds-notification' - -type Args = React.ComponentProps - -export const Notification = (args: Args) => { - return -} - -export default { - title: 'Shared / DS Components / Notification', - component: DSNotification, - args: { - content: ( -
    - ), - }, - argTypes: { - type: { - control: 'radio', - options: ['info', 'error'], - }, - }, - parameters: { - controls: { - include: ['content', 'title', 'type'], - }, - }, -} diff --git a/services/web/frontend/stories/shared/ds/ds-selection-group.stories.tsx b/services/web/frontend/stories/shared/ds/ds-selection-group.stories.tsx deleted file mode 100644 index 4589e407a2..0000000000 --- a/services/web/frontend/stories/shared/ds/ds-selection-group.stories.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { Meta, type StoryObj } from '@storybook/react-webpack5' -import { figmaDesignUrl } from '../../../../.storybook/utils/figma-design-url' -import DSSelectionGroup from '@/shared/components/ds/ds-selection-group' -import DSSelectionGroupItem from '@/shared/components/ds/ds-selection-group-item' - -type Story = StoryObj - -export const SelectionGroupRadio: Story = { - args: { - legend: 'Select your favourite animal', - }, - render: args => { - return ( - - - Wombat - - - Capybara - - - Badger - - - ) - }, -} - -export const SelectionGroupCheckbox: Story = { - args: { - legend: 'Which animals do you like?', - }, - render: args => { - return ( - - - Vole - - - Spider - - - Wombat - - - Capybara - - - Badger - - - ) - }, -} - -const meta: Meta = { - title: 'Shared / DS Components', - component: DSSelectionGroup, - - parameters: { - controls: { - include: ['legend'], - }, - ...figmaDesignUrl( - 'https://www.figma.com/design/aJQlecvqCS9Ry8b6JA1lQN/DS---Components?node-id=6276-265&m=dev' - ), - }, -} - -export default meta diff --git a/services/web/frontend/stories/shared/ds/ds-six-digits-input.stories.tsx b/services/web/frontend/stories/shared/ds/ds-six-digits-input.stories.tsx deleted file mode 100644 index 3e31d973ff..0000000000 --- a/services/web/frontend/stories/shared/ds/ds-six-digits-input.stories.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { ComponentProps, useEffect, useState } from 'react' -import { Meta } from '@storybook/react-webpack5' -import { figmaDesignUrl } from '../../../../.storybook/utils/figma-design-url' -import DSFormGroup from '@/shared/components/ds/ds-form-group' -import DSFormLabel from '@/shared/components/ds/ds-form-label' -import CIAMSixDigitsInput from '@/features/settings/components/emails/ciam-six-digits-input' - -type Args = ComponentProps - -export const SixDigitsInput = ({ value, ...args }: Args) => { - const [state, setState] = useState(value) - useEffect(() => setState(value), [value]) - return ( -
    - - Form input label - setState(e.target.value)} - /> - -
    - ) -} - -const meta: Meta = { - title: 'Shared / DS Components', - component: SixDigitsInput, - argTypes: { - value: { control: 'text' }, - }, - parameters: { - controls: { - include: ['value'], - }, - ...figmaDesignUrl( - 'https://www.figma.com/design/aJQlecvqCS9Ry8b6JA1lQN/DS---Components?node-id=6318-428&t=pcx9KKzhlzpRmA4S-0' - ), - }, -} - -export default meta diff --git a/services/web/frontend/stories/shared/feedback-badge.stories.tsx b/services/web/frontend/stories/shared/feedback-badge.stories.tsx deleted file mode 100644 index aa78e1c3be..0000000000 --- a/services/web/frontend/stories/shared/feedback-badge.stories.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { ScopeDecorator } from '../decorators/scope' -import { FeedbackBadge } from '@/shared/components/feedback-badge' - -export const WithDefaultText = () => { - return ( - - ) -} - -export const WithCustomText = () => { - const FeedbackContent = () => ( - <> - This is an example. -
    - Click to find out more - - ) - - return ( - } - /> - ) -} - -export default { - title: 'Shared / Components / Feedback Badge', - component: FeedbackBadge, - decorators: [ScopeDecorator], -} diff --git a/services/web/frontend/stories/shared/form/form-check.stories.tsx b/services/web/frontend/stories/shared/form/form-check.stories.tsx deleted file mode 100644 index cf94b75024..0000000000 --- a/services/web/frontend/stories/shared/form/form-check.stories.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { useRef, useLayoutEffect } from 'react' -import { Form } from 'react-bootstrap' -import type { Meta, StoryObj } from '@storybook/react-webpack5' -import { figmaDesignUrl } from '../../../../.storybook/utils/figma-design-url' -import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox' - -const meta: Meta<(typeof Form)['Check']> = { - title: 'Shared / Components / Form', - component: OLFormCheckbox, - parameters: { - controls: { - include: ['disabled'], - }, - }, -} -export default meta - -type Story = StoryObj<(typeof Form)['Check']> - -export const Checkbox: Story = { - args: { - id: 'id-1', - label: 'Label', - disabled: false, - }, - parameters: figmaDesignUrl( - 'https://www.figma.com/design/V7Ogph1Ocs4ux2A4WMNAh7/Overleaf---Components?node-id=3494-247203&m=dev' - ), -} - -export const CheckboxChecked: Story = { - args: { - id: 'id-1', - label: 'Label', - disabled: false, - defaultChecked: true, - }, - parameters: figmaDesignUrl( - 'https://www.figma.com/design/V7Ogph1Ocs4ux2A4WMNAh7/Overleaf---Components?node-id=3495-249055&m=dev' - ), -} - -export const CheckboxIndeterminate = (args: Story['args']) => { - const ref = useRef(null) - - useLayoutEffect(() => { - if (ref.current) { - ref.current.indeterminate = true - } - }, []) - - return -} -CheckboxIndeterminate.args = { - id: 'id-2', - label: 'Label', - disabled: false, -} -CheckboxIndeterminate.parameters = figmaDesignUrl( - 'https://www.figma.com/design/V7Ogph1Ocs4ux2A4WMNAh7/Overleaf---Components?node-id=3495-249055&m=dev' -) diff --git a/services/web/frontend/stories/shared/form/form-input.stories.tsx b/services/web/frontend/stories/shared/form/form-input.stories.tsx deleted file mode 100644 index e5f3328d99..0000000000 --- a/services/web/frontend/stories/shared/form/form-input.stories.tsx +++ /dev/null @@ -1,291 +0,0 @@ -import { Form } from 'react-bootstrap' -import type { Meta, StoryObj } from '@storybook/react-webpack5' -import FormGroup from '@/shared/components/form/form-group' -import FormText from '@/shared/components/form/form-text' -import FormControl from '@/shared/components/form/form-control' -import MaterialIcon from '@/shared/components/material-icon' -import FormFeedback from '@/shared/components/form/form-feedback' -import { figmaDesignUrl } from '../../../../.storybook/utils/figma-design-url' - -const meta: Meta> = { - title: 'Shared / Components / Form / Input', - component: FormControl, - parameters: { - controls: { - include: ['disabled'], - }, - }, -} -export default meta - -type Story = StoryObj> - -export const Default: Story = { - render: args => { - return ( - <> - - Label - - Helper - -
    - - Label - - Helper - -
    - - Label - - Helper - - - ) - }, -} -Default.args = { - disabled: false, -} -Default.parameters = figmaDesignUrl( - 'https://www.figma.com/design/V7Ogph1Ocs4ux2A4WMNAh7/Overleaf---Components?node-id=3489-152419&m=dev' -) - -export const Info: Story = { - render: args => { - return ( - <> - - Label - - Info - -
    - - Label - - Info - -
    - - Label - - Info - - - ) - }, - args: { - disabled: false, - }, -} -Info.parameters = figmaDesignUrl( - 'https://www.figma.com/design/V7Ogph1Ocs4ux2A4WMNAh7/Overleaf---Components?node-id=3489-152426&m=dev' -) - -export const Error: Story = { - render: args => { - return ( - <> - - Large input label - - Error - -
    - - Regular input label - - Error - -
    - - Small input label - - Error - - - ) - }, - args: { - disabled: false, - }, -} -Error.parameters = figmaDesignUrl( - 'https://www.figma.com/design/V7Ogph1Ocs4ux2A4WMNAh7/Overleaf---Components?node-id=3489-166648&m=dev' -) - -export const Warning: Story = { - render: args => { - return ( - <> - - Large input label - - Warning - -
    - - Regular input label - - Warning - -
    - - Small input label - - Warning - - - ) - }, - args: { - disabled: false, - }, -} -Warning.parameters = figmaDesignUrl( - 'https://www.figma.com/design/V7Ogph1Ocs4ux2A4WMNAh7/Overleaf---Components?node-id=3489-166648&m=dev' -) - -export const Success: Story = { - render: args => { - return ( - <> - - Large input label - - Success - -
    - - Regular input label - - Success - -
    - - Small input label - - Success - - - ) - }, - args: { - disabled: false, - }, -} -Success.parameters = figmaDesignUrl( - 'https://www.figma.com/design/V7Ogph1Ocs4ux2A4WMNAh7/Overleaf---Components?node-id=3489-166648&m=dev' -) - -export const WithIcons: Story = { - render: args => { - const handleClear = () => { - alert('Clicked clear button') - } - - return ( - <> - - Label - } - append={ - - } - size="lg" - {...args} - /> - -
    - - Label - } - append={ - - } - {...args} - /> - -
    - - Label - } - append={ - - } - size="sm" - {...args} - /> - -
    -
    - - Disabled state - } - append={ - - } - disabled - {...args} - /> - - - ) - }, - args: { - disabled: false, - }, -} diff --git a/services/web/frontend/stories/shared/form/form-radio.stories.tsx b/services/web/frontend/stories/shared/form/form-radio.stories.tsx deleted file mode 100644 index 1685add1ce..0000000000 --- a/services/web/frontend/stories/shared/form/form-radio.stories.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { Form } from 'react-bootstrap' -import type { Meta, StoryObj } from '@storybook/react-webpack5' -import { figmaDesignUrl } from '../../../../.storybook/utils/figma-design-url' - -const meta: Meta<(typeof Form)['Check']> = { - title: 'Shared / Components / Form', - component: Form.Check, - parameters: { - controls: { - include: ['disabled'], - }, - }, -} -export default meta - -type Story = StoryObj<(typeof Form)['Check']> - -export const Radio: Story = { - args: { - id: 'id-1', - type: 'radio', - label: 'Label', - disabled: false, - }, - parameters: figmaDesignUrl( - 'https://www.figma.com/design/V7Ogph1Ocs4ux2A4WMNAh7/Overleaf---Components?node-id=3495-259211&m=dev' - ), -} - -export const RadioChecked: Story = { - args: { - id: 'id-1', - type: 'radio', - label: 'Label', - disabled: false, - defaultChecked: true, - }, - parameters: figmaDesignUrl( - 'https://www.figma.com/design/V7Ogph1Ocs4ux2A4WMNAh7/Overleaf---Components?node-id=3495-259218&m=dev' - ), -} diff --git a/services/web/frontend/stories/shared/form/form-select.stories.tsx b/services/web/frontend/stories/shared/form/form-select.stories.tsx deleted file mode 100644 index 69be442d14..0000000000 --- a/services/web/frontend/stories/shared/form/form-select.stories.tsx +++ /dev/null @@ -1,250 +0,0 @@ -import { Form, FormSelectProps } from 'react-bootstrap' -import type { Meta, StoryObj } from '@storybook/react-webpack5' -import FormGroup from '@/shared/components/form/form-group' -import FormText from '@/shared/components/form/form-text' -import { figmaDesignUrl } from '../../../../.storybook/utils/figma-design-url' - -const meta: Meta = { - title: 'Shared / Components / Form / Select', - component: Form.Select, - parameters: { - controls: { - include: ['disabled'], - }, - }, -} -export default meta - -type Story = StoryObj - -export const Default: Story = { - render: args => { - return ( - <> - - Label - - - - - - - Helper - -
    - - Label - - - - - - - Helper - -
    - - Label - - - - - - - Helper - - - ) - }, -} -Default.args = { - disabled: false, -} -Default.parameters = figmaDesignUrl( - 'https://www.figma.com/design/V7Ogph1Ocs4ux2A4WMNAh7/Overleaf---Components?node-id=3489-199797&m=dev' -) - -export const Info: Story = { - render: args => { - return ( - <> - - Label - - - - - - - Info - -
    - - Label - - - - - - - Info - -
    - - Label - - - - - - - Info - - - ) - }, - args: { - disabled: false, - }, -} - -export const Error: Story = { - render: args => { - return ( - <> - - Label - - - - - - - Error - -
    - - Label - - - - - - - Error - -
    - - Label - - - - - - - Error - - - ) - }, - args: { - disabled: false, - }, -} -Error.parameters = figmaDesignUrl( - 'https://www.figma.com/design/V7Ogph1Ocs4ux2A4WMNAh7/Overleaf---Components?node-id=3489-200123&m=dev' -) - -export const Warning: Story = { - render: args => { - return ( - <> - - Label - - - - - - - Warning - -
    - - Label - - - - - - - Warning - -
    - - Label - - - - - - - Warning - - - ) - }, - args: { - disabled: false, - }, -} -Warning.parameters = figmaDesignUrl( - 'https://www.figma.com/design/V7Ogph1Ocs4ux2A4WMNAh7/Overleaf---Components?node-id=3489-199800&m=dev' -) - -export const Success: Story = { - render: args => { - return ( - <> - - Label - - - - - - - Success - -
    - - Label - - - - - - - Success - -
    - - Label - - - - - - - Success - - - ) - }, - args: { - disabled: false, - }, -} -Success.parameters = figmaDesignUrl( - 'https://www.figma.com/design/V7Ogph1Ocs4ux2A4WMNAh7/Overleaf---Components?node-id=3489-199800&m=dev' -) diff --git a/services/web/frontend/stories/shared/form/form-textarea.stories.tsx b/services/web/frontend/stories/shared/form/form-textarea.stories.tsx deleted file mode 100644 index 6e84249f62..0000000000 --- a/services/web/frontend/stories/shared/form/form-textarea.stories.tsx +++ /dev/null @@ -1,257 +0,0 @@ -import { Form } from 'react-bootstrap' -import type { Meta, StoryObj } from '@storybook/react-webpack5' -import FormGroup from '@/shared/components/form/form-group' -import FormText from '@/shared/components/form/form-text' -import FormControl from '@/shared/components/form/form-control' -import { figmaDesignUrl } from '../../../../.storybook/utils/figma-design-url' - -const meta: Meta> = { - title: 'Shared / Components / Form / Textarea', - component: FormControl, - parameters: { - controls: { - include: ['disabled'], - }, - }, -} -export default meta - -type Story = StoryObj> - -export const Default: Story = { - render: args => { - return ( - <> - - Label - - Helper - -
    - - Label - - Helper - -
    - - Label - - Helper - - - ) - }, -} -Default.args = { - disabled: false, -} -Default.parameters = figmaDesignUrl( - 'https://www.figma.com/design/V7Ogph1Ocs4ux2A4WMNAh7/Overleaf---Components?node-id=3489-176397&m=dev' -) - -export const Info: Story = { - render: args => { - return ( - <> - - Label - - Info - -
    - - Label - - Info - -
    - - Label - - Info - - - ) - }, - args: { - disabled: false, - }, -} - -export const Error: Story = { - render: args => { - return ( - <> - - Label - - Error - -
    - - Label - - Error - -
    - - Label - - Error - - - ) - }, - args: { - disabled: false, - }, -} -Error.parameters = figmaDesignUrl( - 'https://www.figma.com/design/V7Ogph1Ocs4ux2A4WMNAh7/Overleaf---Components?node-id=3489-176403&m=dev' -) - -export const Warning: Story = { - render: args => { - return ( - <> - - Label - - Warning - -
    - - Label - - Warning - -
    - - Label - - Warning - - - ) - }, - args: { - disabled: false, - }, -} -Warning.parameters = figmaDesignUrl( - 'https://www.figma.com/design/V7Ogph1Ocs4ux2A4WMNAh7/Overleaf---Components?node-id=3489-176403&m=dev' -) - -export const Success: Story = { - render: args => { - return ( - <> - - Label - - Success - -
    - - Label - - Success - -
    - - Label - - Success - - - ) - }, - args: { - disabled: false, - }, -} -Success.parameters = figmaDesignUrl( - 'https://www.figma.com/design/V7Ogph1Ocs4ux2A4WMNAh7/Overleaf---Components?node-id=3489-176403&m=dev' -) diff --git a/services/web/frontend/stories/shared/icon-button.stories.tsx b/services/web/frontend/stories/shared/icon-button.stories.tsx deleted file mode 100644 index 1e5ac32673..0000000000 --- a/services/web/frontend/stories/shared/icon-button.stories.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import IconButton from '@/shared/components/button/icon-button' -import type { Meta } from '@storybook/react-webpack5' -import { useTranslation } from 'react-i18next' -import { figmaDesignUrl } from '../../../.storybook/utils/figma-design-url' - -type Args = React.ComponentProps - -export const Icon = (args: Args) => { - const { t } = useTranslation() - - return -} - -const meta: Meta = { - title: 'Shared / Components / IconButton', - component: IconButton, - args: { - disabled: false, - icon: 'add', - isLoading: false, - }, - argTypes: { - size: { - control: 'radio', - options: ['small', 'default', 'large'], - }, - variant: { - control: 'radio', - options: [ - 'primary', - 'secondary', - 'ghost', - 'danger', - 'danger-ghost', - 'premium', - ], - }, - }, - parameters: figmaDesignUrl( - 'https://www.figma.com/design/V7Ogph1Ocs4ux2A4WMNAh7/Overleaf---Components?node-id=3460-168934&m=dev' - ), -} - -export default meta diff --git a/services/web/frontend/stories/shared/input-switch.stories.tsx b/services/web/frontend/stories/shared/input-switch.stories.tsx deleted file mode 100644 index b23826f75f..0000000000 --- a/services/web/frontend/stories/shared/input-switch.stories.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import OLFormSwitch from '@/shared/components/ol/ol-form-switch' -import { figmaDesignUrl } from '../../../.storybook/utils/figma-design-url' - -type Args = React.ComponentProps - -export const Unchecked = (args: Args) => { - return {}} checked={false} {...args} /> -} - -export const UncheckedDisabled = (args: Args) => { - return {}} checked={false} disabled {...args} /> -} - -export const Checked = (args: Args) => { - return {}} checked {...args} /> -} - -export const CheckedDisabled = (args: Args) => { - return {}} checked disabled {...args} /> -} - -export default { - title: 'Shared / Components / Input Switch', - component: OLFormSwitch, - args: { - checked: false, - disabled: false, - }, - parameters: { - controls: { - include: ['checked', 'disabled'], - }, - ...figmaDesignUrl( - 'https://www.figma.com/design/V7Ogph1Ocs4ux2A4WMNAh7/Overleaf---Components?node-id=3489-139487&m=dev' - ), - }, -} diff --git a/services/web/frontend/stories/shared/labeled-divider.stories.tsx b/services/web/frontend/stories/shared/labeled-divider.stories.tsx deleted file mode 100644 index ecf35330db..0000000000 --- a/services/web/frontend/stories/shared/labeled-divider.stories.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import LabeledDivider from '@/shared/components/labeled-divider' -import { Meta } from '@storybook/react-webpack5' - -type Args = React.ComponentProps - -export const Default = (args: Args) => { - return -} - -const meta: Meta = { - title: 'Shared / Components / Labeled Divider', - component: LabeledDivider, - args: { - children: 'Text', - }, -} - -export default meta diff --git a/services/web/frontend/stories/shared/loading-spinner.stories.tsx b/services/web/frontend/stories/shared/loading-spinner.stories.tsx deleted file mode 100644 index fc1907f9c7..0000000000 --- a/services/web/frontend/stories/shared/loading-spinner.stories.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react-webpack5' -import LoadingSpinner, { - FullSizeLoadingSpinner, -} from '@/shared/components/loading-spinner' - -type Story = StoryObj - -export const Default: Story = { - args: { - loadingText: 'Loading content...', - }, -} - -export const WithDelay: Story = { - args: { - delay: 500, - loadingText: 'This will appear after a 500ms delay...', - }, -} - -export const FullSize: StoryObj = { - render: args => , - args: { - loadingText: 'Loading entire section...', - size: 'sm', - }, -} - -const meta: Meta = { - title: 'Shared / Components / Loading Spinner', - component: LoadingSpinner, - parameters: { - layout: 'centered', - controls: { - include: ['loadingText', 'delay', 'size'], - }, - }, - argTypes: { - delay: { - control: 'select', - options: [0, 500], - }, - size: { - control: 'radio', - options: ['lg', 'sm'], - }, - }, - args: { - size: 'sm', - delay: 0, - }, - render: args => , -} - -export default meta diff --git a/services/web/frontend/stories/shared/menu-bar.stories.tsx b/services/web/frontend/stories/shared/menu-bar.stories.tsx deleted file mode 100644 index e1b927eca5..0000000000 --- a/services/web/frontend/stories/shared/menu-bar.stories.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { DropdownDivider } from '@/shared/components/dropdown/dropdown-menu' -import { MenuBar } from '@/shared/components/menu-bar/menu-bar' -import { MenuBarDropdown } from '@/shared/components/menu-bar/menu-bar-dropdown' -import { MenuBarOption } from '@/shared/components/menu-bar/menu-bar-option' -import { Meta } from '@storybook/react-webpack5' - -export const Default = () => { - return ( - - - - - - - - - - - - - - - - - - ) -} - -const meta: Meta = { - title: 'Shared / Components / MenuBar', - component: MenuBar, - argTypes: {}, -} - -export default meta diff --git a/services/web/frontend/stories/shared/modal.stories.tsx b/services/web/frontend/stories/shared/modal.stories.tsx deleted file mode 100644 index 109f06d1f2..0000000000 --- a/services/web/frontend/stories/shared/modal.stories.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react-webpack5' -import { figmaDesignUrl } from './../../../.storybook/utils/figma-design-url' -import { - OLModal, - OLModalHeader, - OLModalBody, - OLModalFooter, - OLModalTitle, -} from '@/shared/components/ol/ol-modal' -import OLButton from '@/shared/components/ol/ol-button' - -type Story = StoryObj - -export const Default: Story = { - args: { - title: 'Heading', - children: ( -

    - Always use the modal actions in the footer and use descriptive words for - them. -

    - ), - footer: ( - <> - Cancel - Primary - - ), - }, - parameters: figmaDesignUrl( - 'https://www.figma.com/design/V7Ogph1Ocs4ux2A4WMNAh7/Overleaf---Components?m=auto&node-id=3488-82657&m=dev' - ), -} - -export const Informative: Story = { - parameters: figmaDesignUrl( - 'https://www.figma.com/design/V7Ogph1Ocs4ux2A4WMNAh7/Overleaf---Components?m=auto&node-id=3488-86576&m=dev' - ), - args: { - title: 'Informative', - children: ( -

    - Presents information for the user to be aware of and doesn’t require any - action. -

    - ), - }, -} - -export const Acknowledgment: Story = { - parameters: figmaDesignUrl( - 'https://www.figma.com/design/V7Ogph1Ocs4ux2A4WMNAh7/Overleaf---Components?m=auto&node-id=3488-86581&m=dev' - ), - args: { - title: 'Acknowledgment', - children: ( -

    - System requires an acknowledgment from the user. Usually contains only - one primary button. -

    - ), - footer: Accept, - }, -} - -export const Danger: Story = { - parameters: figmaDesignUrl( - 'https://www.figma.com/design/V7Ogph1Ocs4ux2A4WMNAh7/Overleaf---Components?m=auto&node-id=3488-86586&m=dev' - ), - args: { - title: 'Danger', - children:

    Used for destructive or irreversible actions.

    , - footer: ( - <> - Cancel - Delete - - ), - }, -} - -const meta: Meta = { - title: 'Shared / Components / Modal', - component: OLModal, - parameters: { - controls: { - include: ['size', 'title'], - }, - }, - argTypes: { - size: { - control: 'radio', - options: ['lg', 'md', 'sm'], - }, - }, - args: { - show: true, - size: 'sm', - onHide: () => {}, - }, - render: ({ title, children, footer, ...args }) => ( - - - {title} - - {children} - {footer && {footer}} - - ), -} - -export default meta diff --git a/services/web/frontend/stories/shared/notification.stories.tsx b/services/web/frontend/stories/shared/notification.stories.tsx deleted file mode 100644 index 459f0f5138..0000000000 --- a/services/web/frontend/stories/shared/notification.stories.tsx +++ /dev/null @@ -1,437 +0,0 @@ -import fetchMock from 'fetch-mock' -import Notification from '../../js/shared/components/notification' -import { postJSON } from '../../js/infrastructure/fetch-json' -import useAsync from '../../js/shared/hooks/use-async' -import { figmaDesignUrl } from '../../../.storybook/utils/figma-design-url' - -type Args = React.ComponentProps - -export const NotificationInfo = (args: Args) => { - return -} -NotificationInfo.parameters = figmaDesignUrl( - 'https://www.figma.com/design/V7Ogph1Ocs4ux2A4WMNAh7/Overleaf---Components?node-id=3488-103958&m=dev' -) - -export const NotificationSuccess = (args: Args) => { - return -} -NotificationSuccess.parameters = figmaDesignUrl( - 'https://www.figma.com/design/V7Ogph1Ocs4ux2A4WMNAh7/Overleaf---Components?node-id=3489-110077&m=dev' -) - -export const NotificationWarning = (args: Args) => { - return -} -NotificationWarning.parameters = figmaDesignUrl( - 'https://www.figma.com/design/V7Ogph1Ocs4ux2A4WMNAh7/Overleaf---Components?node-id=3489-112036&m=dev' -) - -export const NotificationError = (args: Args) => { - return -} -NotificationError.parameters = figmaDesignUrl( - 'https://www.figma.com/design/V7Ogph1Ocs4ux2A4WMNAh7/Overleaf---Components?node-id=3489-112059&m=dev' -) - -export const NotificationOffer = (args: Args) => { - return -} - -export const NotificationWithActionBelowContent = (args: Args) => { - return ( - -

    The CTA will always go below the content on small screens.

    -

    - We can also opt to always put the CTA below the content on all - screens -

    - - } - isDismissible - isActionBelowContent - /> - ) -} -NotificationWithActionBelowContent.parameters = figmaDesignUrl( - 'https://www.figma.com/design/V7Ogph1Ocs4ux2A4WMNAh7/Overleaf---Components?node-id=3489-118816&m=dev' -) - -export const NotificationWithTitle = (args: Args) => { - return -} -NotificationWithTitle.parameters = figmaDesignUrl( - 'https://www.figma.com/design/V7Ogph1Ocs4ux2A4WMNAh7/Overleaf---Components?node-id=3489-115045&m=dev' -) - -export const NotificationWithAction = (args: Args) => { - return -} -NotificationWithAction.parameters = figmaDesignUrl( - 'https://www.figma.com/design/V7Ogph1Ocs4ux2A4WMNAh7/Overleaf---Components?node-id=3489-117190&m=dev' -) - -export const NotificationDismissible = (args: Args) => { - return -} -NotificationDismissible.parameters = figmaDesignUrl( - 'https://www.figma.com/design/V7Ogph1Ocs4ux2A4WMNAh7/Overleaf---Components?node-id=3489-116677&m=dev' -) - -export const APlainNotification = (args: Args) => { - return -} - -export const NotificationWithMultipleParagraphsAndActionAndDismissible = ( - args: Args -) => { - return ( - -

    - Lorem ipsum -

    -

    - Dolor sit amet, consectetur adipiscing elit. Proin lacus velit, - faucibus vitae feugiat sit amet, Some link iaculis - ut mi. -

    -

    - Vel eros donec ac odio tempor orci dapibus ultrices in. Fermentum - iaculis eu non diam phasellus. -

    -

    Aliquam at tempor risus. Vestibulum bibendum ut

    - - } - /> - ) -} - -export const NotificationWithMultipleParagraphsAndDismissible = ( - args: Args -) => { - return ( - -

    - Lorem ipsum -

    -

    - Dolor sit amet, consectetur adipiscing elit. Proin lacus velit, - faucibus vitae feugiat sit amet, Some link iaculis - ut mi. -

    -

    - Vel eros donec ac odio tempor orci dapibus ultrices in. Fermentum - iaculis eu non diam phasellus. -

    -

    Aliquam at tempor risus. Vestibulum bibendum ut

    - - } - /> - ) -} - -export const MultipleParagraphsAndAction = (args: Args) => { - return ( - -

    - Lorem ipsum -

    -

    - Dolor sit amet, consectetur adipiscing elit. Proin lacus velit, - faucibus vitae feugiat sit amet, Some link iaculis - ut mi. -

    -

    - Vel eros donec ac odio tempor orci dapibus ultrices in. Fermentum - iaculis eu non diam phasellus. -

    -

    Aliquam at tempor risus. Vestibulum bibendum ut

    - - } - /> - ) -} - -export const MultipleParagraphs = (args: Args) => { - return ( - -

    - Lorem ipsum -

    -

    - Dolor sit amet, consectetur adipiscing elit. Proin lacus velit, - faucibus vitae feugiat sit amet, Some link iaculis - ut mi. -

    -

    - Vel eros donec ac odio tempor orci dapibus ultrices in. Fermentum - iaculis eu non diam phasellus. -

    -

    Aliquam at tempor risus. Vestibulum bibendum ut

    - - } - /> - ) -} - -export const ShortText = (args: Args) => { - return ( - Lorem ipsum

    } - /> - ) -} - -export const ShortTextAndDismissible = (args: Args) => { - return ( - Lorem ipsum

    } /> - ) -} - -export const ShortTextAndActionLinkAsButton = (args: Args) => { - return ( - Lorem ipsum

    } - /> - ) -} - -export const ShortTextAndActionAsLink = (args: Args) => { - return ( - Lorem ipsum

    } - action={An action} - isDismissible={false} - /> - ) -} -export const ShortTextAndActionAsLinkButStyledAsButton = (args: Args) => { - return ( - Lorem ipsum

    } - action={ - - An action - - } - isDismissible={false} - /> - ) -} - -export const LongActionButton = (args: Args) => { - return ( - - Action that has a lot of text - - } - /> - ) -} - -export const LongActionLink = (args: Args) => { - return ( - Action that has a lot of text} - /> - ) -} - -export const CustomIcon = (args: Args) => { - return ( - 🎉} - /> - ) -} - -export const MultipleButtons = (args: Args) => { - return ( - Lorem ipsum

    } - action={ - <> - - - - } - type="info" - isActionBelowContent - isDismissible - /> - ) -} - -export const OverlayedWithCustomClass = (args: Args) => { - return ( - <> - - This can be any HTML passed to the component. For example, - paragraphs, headers, code samples,{' '} - links, etc are all supported. -

    - } - className="ol-overlay" - action={ - <> - - - - } - type="info" - isActionBelowContent - isDismissible - /> -
    -

    we need filler content, so here are some jokes

    -
      -
    • Did you hear about the circus fire? It was in tents!
    • -
    • How do you catch a squirrel? Climb a tree and act like a nut!
    • -
    • - Did you hear about the guy who invented Lifesavers? They say he made - a mint! -
    • -
    • - Why couldn't the bicycle stand up by itself? It was two tired. -
    • -
    • - did one hat say to the other?" "Stay here! I'm going on ahead. -
    • -
    • - Why did Billy get fired from the banana factory? He kept throwing - away the bent ones. -
    • -
    -
    - - ) -} - -export const SuccessFlow = (args: Args) => { - console.log('.....render') - fetchMock.post('express:/test-success', { status: 200 }, { delay: 250 }) - - const { isLoading, isSuccess, runAsync } = useAsync() - function handleClick() { - console.log('clicked') - runAsync(postJSON('/test-success')).catch(console.error) - } - - const ctaText = isLoading ? 'Processing' : 'Click' - const action = ( - - ) - - const startNotification = ( - - This story shows 2 notifications, and it's up to the parent component - to determine which to show. There's a successful request made after - clicking the action and so the parent component then renders the - success notification. -

    - } - /> - ) - const successNotification = ( - Now follow this link to go home} - type="success" - content={

    Success! You made a successful request.

    } - /> - ) - - if (isSuccess) return successNotification - return startNotification -} - -export const ContentAsAString = (args: Args) => { - return -} - -export default { - title: 'Shared / Components / Notification', - component: Notification, - args: { - content: ( -

    - This can be any HTML passed to the component. For example, - paragraphs, headers, code samples, links, - etc are all supported. -

    - ), - action: , - isDismissible: true, - title: undefined, - }, - argTypes: { - content: { - control: 'text', - }, - action: { - control: 'text', - }, - title: { - control: 'text', - }, - }, - parameters: { - controls: { - include: [ - 'content', - 'title', - 'action', - 'ariaLive', - 'type', - 'isDismissible', - 'isActionBelowContent', - ], - }, - }, -} diff --git a/services/web/frontend/stories/shared/pagination.stories.jsx b/services/web/frontend/stories/shared/pagination.stories.jsx deleted file mode 100644 index 75b365fa24..0000000000 --- a/services/web/frontend/stories/shared/pagination.stories.jsx +++ /dev/null @@ -1,23 +0,0 @@ -import Pagination from '../../js/shared/components/pagination' - -export const Interactive = args => { - return -} - -export default { - title: 'Shared / Components / Pagination', - component: Pagination, - args: { - currentPage: 1, - totalPages: 10, - handlePageClick: () => {}, - }, - argTypes: { - currentPage: { control: { type: 'number', min: 1, max: 10, step: 1 } }, - }, - parameters: { - controls: { - include: ['currentPage'], - }, - }, -} diff --git a/services/web/frontend/stories/shared/radio-chip.stories.tsx b/services/web/frontend/stories/shared/radio-chip.stories.tsx deleted file mode 100644 index a782363faf..0000000000 --- a/services/web/frontend/stories/shared/radio-chip.stories.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import RadioChip from '../../js/shared/components/radio-chip' - -type Args = React.ComponentProps - -export const RadioChipDefault = (args: Args) => { - return -} - -export const RadioChipDisabled = (args: Args) => { - return -} - -export const RadioChipDisabledSelected = (args: Args) => { - return -} - -export default { - title: 'Shared / Components / RadioChip', - component: RadioChip, - args: { - label: 'Option', - checked: false, - disabled: false, - }, - argTypes: { - label: { - control: 'text', - }, - }, - parameters: { - controls: { - include: ['label', 'checked', 'disabled', 'name', 'value'], - }, - }, -} diff --git a/services/web/frontend/stories/shared/row.stories.tsx b/services/web/frontend/stories/shared/row.stories.tsx deleted file mode 100644 index d5ce7ca719..0000000000 --- a/services/web/frontend/stories/shared/row.stories.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Container, Row, Col } from 'react-bootstrap' -import { Meta } from '@storybook/react-webpack5' - -type Args = React.ComponentProps - -export const ColumnRowCell = (args: Args) => { - return ( - - - -
    Col 1
    - - -
    Col 2
    - - -
    Col 3
    - -
    -
    - ) -} - -const meta: Meta = { - title: 'Shared / Components / Column-Row-Cell', - component: Row, -} - -export default meta diff --git a/services/web/frontend/stories/shared/select.stories.tsx b/services/web/frontend/stories/shared/select.stories.tsx deleted file mode 100644 index 1e792872dc..0000000000 --- a/services/web/frontend/stories/shared/select.stories.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { Select } from '@/shared/components/select' -import { Meta } from '@storybook/react-webpack5' - -type Args = Pick< - React.ComponentProps, - 'disabled' | 'defaultText' | 'isCiam' -> - -const items = [1, 2, 3, 4].map(index => ({ - key: index, - value: `Demo item ${index}`, - group: index >= 3 ? 'Large numbers' : undefined, -})) - -export const Base = (args: Args) => { - return ( - String(x?.value)} - itemToKey={x => String(x.key)} - itemToSubtitle={x => x?.group ?? ''} - /> - ) -} - -export const WithSelectedIcon = (args: Args) => { - return ( - String(x?.value)} - itemToKey={x => String(x.key)} - itemToDisabled={x => x?.key === 1} - itemToSubtitle={x => x?.group ?? ''} - /> - ) -} - -const meta: Meta = { - title: 'Shared / Components / Select', - component: Select, - parameters: { - controls: { - include: ['disabled', 'defaultText', 'isCiam'], - }, - }, - args: { - disabled: false, - isCiam: false, - defaultText: 'Choose an item', - }, -} - -export default meta diff --git a/services/web/frontend/stories/shared/split-button.stories.tsx b/services/web/frontend/stories/shared/split-button.stories.tsx deleted file mode 100644 index 2fe772267e..0000000000 --- a/services/web/frontend/stories/shared/split-button.stories.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { Fragment } from 'react' -import type { Meta } from '@storybook/react-webpack5' -import { useTranslation } from 'react-i18next' -import { - Dropdown, - DropdownHeader, - DropdownItem, - DropdownMenu, - DropdownToggle, -} from '@/shared/components/dropdown/dropdown-menu' -import Button from '@/shared/components/button/button' -import { ButtonGroup } from 'react-bootstrap' -import { figmaDesignUrl } from '../../../.storybook/utils/figma-design-url' - -export const Sizes = () => { - const { t } = useTranslation() - const sizes = { - Large: 'lg', - Regular: undefined, - Small: 'sm', - } as const - const variants = ['primary', 'secondary', 'danger'] as const - - return Object.entries(sizes).map(([label, size]) => ( - -

    {label}

    -
    - {variants.map(variant => ( - - - - - Header - Action 1 - Action 2 - Action 3 - - - ))} -
    -
    - )) -} -const meta: Meta = { - title: 'Shared/Components/SplitButton', - component: Dropdown, - args: { - align: { sm: 'start' }, - }, - parameters: figmaDesignUrl( - 'https://www.figma.com/design/V7Ogph1Ocs4ux2A4WMNAh7/Overleaf---Components?node-id=3460-194077&m=dev' - ), -} - -export default meta diff --git a/services/web/frontend/stories/shared/split-test-badge.stories.tsx b/services/web/frontend/stories/shared/split-test-badge.stories.tsx deleted file mode 100644 index 85d876a8fd..0000000000 --- a/services/web/frontend/stories/shared/split-test-badge.stories.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react-webpack5' -import SplitTestBadge from '../../js/shared/components/split-test-badge' -import { SplitTestContext } from '../../js/shared/context/split-test-context' - -type Story = StoryObj - -const splitTestContextValue = { - splitTestVariants: {} as Record, - splitTestInfo: {} as Record, -} - -export const Alpha: Story = { - render: args => { - splitTestContextValue.splitTestVariants = { - 'storybook-test': 'active', - } - splitTestContextValue.splitTestInfo = { - 'storybook-test': { - phase: 'alpha', - badgeInfo: { - url: '/alpha/participate', - tooltipText: 'This is an alpha feature', - }, - }, - } - - return - }, -} - -export const AlphaNotDisplayed: Story = { - render: args => { - splitTestContextValue.splitTestVariants = { - 'storybook-test': 'default', - } - splitTestContextValue.splitTestInfo = { - 'storybook-test': { - phase: 'alpha', - badgeInfo: { - url: '/alpha/participate', - tooltipText: 'This is an alpha feature', - }, - }, - } - - return - }, -} - -export const Beta: Story = { - render: args => { - splitTestContextValue.splitTestVariants = { - 'storybook-test': 'active', - } - splitTestContextValue.splitTestInfo = { - 'storybook-test': { - phase: 'beta', - badgeInfo: { - url: '/beta/participate', - tooltipText: 'This is a beta feature', - }, - }, - } - - return - }, -} - -export const BetaNotDisplayed: Story = { - render: args => { - splitTestContextValue.splitTestVariants = { - 'storybook-test': 'default', - } - splitTestContextValue.splitTestInfo = { - 'storybook-test': { - phase: 'beta', - badgeInfo: { - url: '/beta/participate', - tooltipText: 'This is a beta feature', - }, - }, - } - - return - }, -} - -export const Release: Story = { - render: args => { - splitTestContextValue.splitTestVariants = { - 'storybook-test': 'active', - } - splitTestContextValue.splitTestInfo = { - 'storybook-test': { - phase: 'release', - badgeInfo: { - url: '/feedback/form', - tooltipText: 'This is a new feature', - }, - }, - } - - return - }, -} - -export const ReleaseNotDisplayed: Story = { - render: args => { - splitTestContextValue.splitTestVariants = { - 'storybook-test': 'default', - } - splitTestContextValue.splitTestInfo = { - 'storybook-test': { - phase: 'release', - badgeInfo: { - url: '/feedback/form', - tooltipText: 'This is a new feature', - }, - }, - } - - return - }, -} - -const meta: Meta = { - title: 'Shared / Components / Split Test Badge', - component: SplitTestBadge, - parameters: { - controls: { - include: ['splitTestName', 'tooltip'], - }, - }, - argTypes: { - splitTestName: { control: 'text' }, - tooltip: { control: 'text' }, - }, - args: { - splitTestName: 'storybook-test', - displayOnVariants: ['active'], - }, - decorators: [ - Story => ( - - - - ), - ], -} - -export default meta diff --git a/services/web/frontend/stories/shared/start-free-trial-button.stories.tsx b/services/web/frontend/stories/shared/start-free-trial-button.stories.tsx deleted file mode 100644 index 7b679a0f45..0000000000 --- a/services/web/frontend/stories/shared/start-free-trial-button.stories.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react-webpack5' -import StartFreeTrialButton from '../../js/shared/components/start-free-trial-button' -import type { ButtonProps } from '../../js/shared/components/types/button-props' - -type Args = React.ComponentProps & { - size?: ButtonProps['size'] -} - -type Story = StoryObj - -export const Default: Story = { - render: args => { - const { size, ...startFreeTrialProps } = args - return ( - - ) - }, -} - -export const CustomText: Story = { - render: args => { - const { size, ...startFreeTrialProps } = args - return ( - - Some Custom Text! - - ) - }, -} - -export const ButtonStyle: Story = { - render: args => { - const { size: _size, ...startFreeTrialProps } = args - return ( - - ) - }, -} - -const meta: Meta = { - title: 'Shared / Components / Start Free Trial Button', - component: StartFreeTrialButton, - parameters: { - controls: { - include: ['source', 'variant', 'children', 'size'], - }, - }, - argTypes: { - source: { control: 'text' }, - buttonProps: { control: false }, - children: { control: 'text' }, - size: { - control: 'radio', - options: ['lg', 'md', 'sm'], - }, - variant: { - control: 'radio', - options: [ - 'primary', - 'secondary', - 'ghost', - 'danger', - 'danger-ghost', - 'premium', - 'premium-secondary', - 'link', - ], - }, - handleClick: { control: false }, - segmentation: { control: false }, - extraSearchParams: { control: false }, - }, - args: { - source: 'storybook', - variant: 'secondary', - size: undefined, - children: 'Start free trial', - }, -} - -export default meta diff --git a/services/web/frontend/stories/shared/tag.stories.tsx b/services/web/frontend/stories/shared/tag.stories.tsx deleted file mode 100644 index 61003a2ae8..0000000000 --- a/services/web/frontend/stories/shared/tag.stories.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import OLTagIcon from '@/shared/components/ol/ol-tag-icon' -import Tag from '@/shared/components/tag' -import type { Meta, StoryObj } from '@storybook/react-webpack5' -import { figmaDesignUrl } from '../../../.storybook/utils/figma-design-url' - -const meta: Meta = { - title: 'Shared / Components / Tag', - component: Tag, - args: { - children: 'Tag', - }, - argTypes: { - prepend: { - table: { - disable: true, - }, - }, - className: { - table: { - disable: true, - }, - }, - closeBtnProps: { - table: { - disable: true, - }, - }, - }, -} -export default meta - -type Story = StoryObj - -export const Default: Story = { - render: args => { - return - }, -} -Default.parameters = figmaDesignUrl( - 'https://www.figma.com/design/V7Ogph1Ocs4ux2A4WMNAh7/Overleaf---Components?node-id=3460-244790&m=dev' -) - -export const Prepend: Story = { - render: args => { - return } {...args} /> - }, -} -Prepend.parameters = figmaDesignUrl( - 'https://www.figma.com/design/V7Ogph1Ocs4ux2A4WMNAh7/Overleaf---Components?node-id=3460-245386&m=dev' -) - -export const Removable: Story = { - render: args => { - return ( - } - closeBtnProps={{ - onClick: () => alert('Close triggered!'), - }} - {...args} - /> - ) - }, -} -Removable.parameters = figmaDesignUrl( - 'https://www.figma.com/design/V7Ogph1Ocs4ux2A4WMNAh7/Overleaf---Components?node-id=3460-238534&m=dev' -) - -export const InteractiveRemovable: Story = { - render: args => { - return ( - } - contentProps={{ - onClick: () => alert('Content button clicked!'), - }} - closeBtnProps={{ - onClick: () => alert('Close triggered!'), - }} - {...args} - /> - ) - }, -} diff --git a/services/web/frontend/stories/shared/toggle-button.stories.tsx b/services/web/frontend/stories/shared/toggle-button.stories.tsx deleted file mode 100644 index bec2981110..0000000000 --- a/services/web/frontend/stories/shared/toggle-button.stories.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import OLToggleButton from '@/shared/components/ol/ol-toggle-button' -import OLToggleButtonGroup from '@/shared/components/ol/ol-toggle-button-group' -import { figmaDesignUrl } from '../../../.storybook/utils/figma-design-url' - -export const Default = () => { - return ( - - - ¼ width - - - ½ width - - - ¾ width - - - Full width - - - ) -} - -export const Disabled = () => { - return ( - - - ¼ width - - - ½ width - - - ¾ width - - - Full width - - - ) -} - -export default { - title: 'Shared / Components / Toggle Button Group', - component: OLToggleButtonGroup, - parameters: figmaDesignUrl( - 'https://www.figma.com/design/V7Ogph1Ocs4ux2A4WMNAh7/Overleaf---Components?node-id=3460-191910&m=dev' - ), -} diff --git a/services/web/frontend/stories/shared/tooltip.stories.tsx b/services/web/frontend/stories/shared/tooltip.stories.tsx deleted file mode 100644 index cb5f9cd162..0000000000 --- a/services/web/frontend/stories/shared/tooltip.stories.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import OLButton from '@/shared/components/ol/ol-button' -import OLTooltip from '@/shared/components/ol/ol-tooltip' -import { Meta } from '@storybook/react-webpack5' -import { figmaDesignUrl } from '../../../.storybook/utils/figma-design-url' - -export const Tooltips = () => { - const placements = ['top', 'right', 'bottom', 'left'] as const - - return ( -
    - {placements.map(placement => ( - - Tooltip on {placement} - - ))} -
    - ) -} - -const meta: Meta = { - title: 'Shared / Components / Tooltip', - component: OLTooltip, - parameters: figmaDesignUrl( - 'https://www.figma.com/design/V7Ogph1Ocs4ux2A4WMNAh7/Overleaf---Components?node-id=3460-237285&m=dev' - ), -} - -export default meta diff --git a/services/web/frontend/stories/shared/upgrade-prompt.stories.tsx b/services/web/frontend/stories/shared/upgrade-prompt.stories.tsx deleted file mode 100644 index 39b740d16a..0000000000 --- a/services/web/frontend/stories/shared/upgrade-prompt.stories.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { UpgradePrompt } from '@/shared/components/upgrade-prompt' -import { StoryObj } from '@storybook/react-webpack5' - -type Story = StoryObj - -export const Generic: Story = { - args: { - title: 'Unlock more compile time', - summary: 'Your project took too long to compile and timed out.', - onClose: () => {}, - planPricing: { student: '$9', standard: '$21' }, - itmCampaign: 'storybook', - }, -} - -export default { - title: 'Shared / Components / Upgrade Prompt', - component: UpgradePrompt, -} diff --git a/services/web/frontend/stories/source-editor/source-editor.stories.tsx b/services/web/frontend/stories/source-editor/source-editor.stories.tsx deleted file mode 100644 index af130cacea..0000000000 --- a/services/web/frontend/stories/source-editor/source-editor.stories.tsx +++ /dev/null @@ -1,553 +0,0 @@ -import SourceEditor from '../../js/features/source-editor/components/source-editor' -import { ScopeDecorator } from '../decorators/scope' -import { useScope } from '../hooks/use-scope' -import { useMeta } from '../hooks/use-meta' -import React, { FC, useState } from 'react' -import { FileTreePathContext } from '@/features/file-tree/contexts/file-tree-path' -import RangesTracker from '@overleaf/ranges-tracker' -import useExposedState from '@/shared/hooks/use-exposed-state' -import { EditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context' -import { DocId } from '../../../types/project-settings' -import { StoryObj } from '@storybook/react-webpack5' -import { DocumentContainer } from '@/features/ide-react/editor/document-container' -import { EditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context' - -type Story = StoryObj - -const EditorOpenDocProvider: FC< - React.PropsWithChildren<{ - initialOpenDocName: string | null - initialDocument: DocumentContainer - }> -> = ({ children, initialOpenDocName, initialDocument }) => { - const [currentDocumentId, setCurrentDocumentId] = - useExposedState(null, 'editor.open_doc_id') - const [openDocName, setOpenDocName] = useExposedState( - initialOpenDocName, - 'editor.open_doc_name' - ) - const [currentDocument, setCurrentDocument] = - useState(initialDocument) - - const value = { - currentDocumentId, - setCurrentDocumentId, - openDocName, - setOpenDocName, - currentDocument, - setCurrentDocument, - } - - return ( - - {children} - - ) -} - -const LatexEditorOpenDocProvider: FC = ({ - children, -}) => ( - - {children} - -) - -const MarkdownEditorOpenDocProvider: FC = ({ - children, -}) => ( - - {children} - -) - -const BibtexEditorOpenDocProvider: FC = ({ - children, -}) => ( - - {children} - -) - -const VisualEditorPropertiesProvider: FC = ({ - children, -}) => { - const [showVisual, setShowVisual] = useState(true) - - const value = { - showVisual, - setShowVisual, - showVisualForFile: () => showVisual, - showSymbolPalette: true, - setShowSymbolPalette: () => undefined, - toggleSymbolPalette: () => undefined, - opening: true, - setOpening: () => undefined, - trackChanges: false, - setTrackChanges: () => undefined, - wantTrackChanges: false, - setWantTrackChanges: () => undefined, - errorState: false, - setErrorState: () => undefined, - } - - return ( - - {children} - - ) -} - -const FileTreePathProvider: FC = ({ children }) => ( - null, - findEntityByPath: () => null, - pathInFolder: () => null, - previewByPath: (path: string) => - path === 'frog.jpg' - ? { - extension: 'png', - url: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAASwAAAEsCAYAAAB5fY51AAABhWlDQ1BJQ0MgcHJvZmlsZQAAKJF9kT1Iw0AcxV9TpaJVETuIOGSogmBBVMRRq1CECqFWaNXB5NIvaNKQpLg4Cq4FBz8Wqw4uzro6uAqC4AeIq4uToouU+L+k0CLWg+N+vLv3uHsHCNUi06y2cUDTbTMRi4qp9KoYeIWAPnShB6Mys4w5SYqj5fi6h4+vdxGe1frcn6NbzVgM8InEs8wwbeIN4ulN2+C8TxxieVklPiceM+mCxI9cVzx+45xzWeCZITOZmCcOEYu5JlaamOVNjXiKOKxqOuULKY9VzluctWKZ1e/JXxjM6CvLXKc5hBgWsQQJIhSUUUARNiK06qRYSNB+tIV/0PVL5FLIVQAjxwJK0CC7fvA/+N2tlZ2c8JKCUaD9xXE+hoHALlCrOM73sePUTgD/M3ClN/ylKjDzSXqloYWPgN5t4OK6oSl7wOUOMPBkyKbsSn6aQjYLvJ/RN6WB/lugc83rrb6P0wcgSV3Fb4CDQ2AkR9nrLd7d0dzbv2fq/f0ARfNylZJUgMQAAAAGYktHRABuAP8AAGHZRr4AAAAJcEhZcwAALiMAAC4jAXilP3YAAAAHdElNRQfnAhELEhgyPeVkAAAAGXRFWHRDb21tZW50AENyZWF0ZWQgd2l0aCBHSU1QV4EOFwAAAyVJREFUeNrt1rEJgDAURVGVNCmS2hS6PziCteIYWjuEbiEfOWeEV1xe35bt6QhjnlYjBJLzbYRABhMAggUgWIBgAQgWgGABggUgWACCBQgWgGABCBYgWACCBSBYgGABCBaAYAGCBSBYAIIFCBaAYAEIFiBYAIIFIFiAYAEIFiBYAIIFIFiAYAEIFoBgAYIFIFgAggUIFoBgAQgWIFgAggUgWIBgAQgWgGABggUgWACCBQgWgGABCBYgWACCBSBYgGABCBYgWACCBSBYgGABCBaAYAGCBSBYAIIFCBaAYAEIFiBYAIIFIFiAYAEIFoBgAYIFIFgAggUIFoBgAQgWIFgAggUgWIBgAQgWIFgAggUgWIBgAQgWgGABggUgWACCBQgWgGABCBbwX6m13QqB5HwbIZBSLyN4WACCBQgWgGABCBYgWACCBSBYgGABCBaAYAGCBSBYAIIFCBaAYAEIFiBYAIIFCBaAYAEIFiBYAIIFIFiAYAEIFoBgAYIFIFgAggUIFoBgAQgWIFgAggUgWIBgAQgWgGABggUgWACCBQgWgGABCBYgWACCBQgWgGABCBYgWACCBSBYgGABCBaAYAGCBSBYAIIFCBaAYAEIFiBYAIIFIFiAYAEIFoBgAYIFIFgAggUIFoBgAQgWIFgAggUIFoBgAQgWIFgAggUgWIBgAQgWgGABggUgWACCBQgWgGABCBYgWACCBSBYgGABCBaAYAGCBfCFVMtphUBKvYwQSBsPI3hYAIIFCBaAYAEIFiBYAIIFIFiAYAEIFoBgAYIFIFgAggUIFoBgAQgWIFgAggUIFoBgAQgWIFgAggUgWIBgAQgWgGABggUgWACCBQgWgGABCBYgWACCBSBYgGABCBaAYAGCBSBYAIIFCBaAYAEIFiBYAIIFCBaAYAEIFiBYAIIFIFiAYAEIFoBgAYIFIFgAggUIFoBgAQgWIFgAggUgWIBgAQgWgGABggUgWACCBQgWgGABCBYgWACCBQgWgGABCBYgWACCBSBYgGABCBaAYAGCBSBYAIIFCBaAYAEIFiBYAIIFIFiAYAEIFoBgAYIF8IUXjtUMuBMh1xAAAAAASUVORK5CYII=', - } - : null, - }} - > - {children} - -) - -export default { - title: 'Editor / Source Editor', - component: SourceEditor, - decorators: [ - (Story: any) => ( -
    - -
    - ), - ], -} - -const settings = { - fontSize: 12, - fontFamily: 'monaco', - lineHeight: 'normal', - editorTheme: 'textmate', - overallTheme: '', - mode: 'default', - autoComplete: true, - autoPairDelimiters: true, - trackChanges: true, - syntaxValidation: false, -} - -const permissions = { - write: true, -} - -export const Latex: Story = { - decorators: [ - Story => - ScopeDecorator(Story, { - mockCompileOnLoad: true, - providers: { - FileTreePathProvider, - EditorOpenDocProvider: LatexEditorOpenDocProvider, - }, - }), - - (Story, { globals }) => { - // FIXME: useScope has no effect - useScope({ - rootFolder: { - name: 'rootFolder', - id: 'root-folder-id', - type: 'folder', - children: [ - { - name: 'example.tex.tex', - id: 'example-doc-id', - type: 'doc', - selected: false, - $$hashKey: 'object:89', - }, - { - name: 'frog.jpg', - id: 'frog-image-id', - type: 'file', - linkedFileData: null, - created: '2023-05-04T16:11:04.352Z', - $$hashKey: 'object:108', - }, - ], - selected: false, - }, - settings: { - ...settings, - overallTheme: globals.theme === 'default-' ? '' : globals.theme, - }, - permissions, - }) - - useMeta({ - 'ol-showSymbolPalette': true, - }) - - return - }, - ], -} - -export const Markdown: Story = { - decorators: [ - Story => - ScopeDecorator(Story, { - mockCompileOnLoad: true, - providers: { - FileTreePathProvider, - EditorOpenDocProvider: MarkdownEditorOpenDocProvider, - }, - }), - - (Story, { globals }) => { - // FIXME: useScope has no effect - useScope({ - settings: { - ...settings, - overallTheme: globals.theme === 'default-' ? '' : globals.theme, - }, - permissions, - }) - - return - }, - ], -} - -export const Visual: Story = { - decorators: [ - Story => - ScopeDecorator(Story, { - mockCompileOnLoad: true, - providers: { - FileTreePathProvider, - EditorOpenDocProvider: LatexEditorOpenDocProvider, - EditorPropertiesProvider: VisualEditorPropertiesProvider, - }, - }), - - (Story, { globals }) => { - // FIXME: useScope has no effect, so this does nothing - useScope({ - settings: { - ...settings, - overallTheme: globals.theme === 'default-' ? '' : globals.theme, - }, - permissions, - }) - - useMeta({ - 'ol-mathJaxPath': 'https://unpkg.com/mathjax@4.1.2/tex-svg.js', - 'ol-project_id': '63e21c07946dd8c76505f85a', - }) - - return - }, - ], -} - -export const Bibtex: Story = { - decorators: [ - Story => - ScopeDecorator(Story, { - mockCompileOnLoad: true, - providers: { - FileTreePathProvider, - EditorOpenDocProvider: BibtexEditorOpenDocProvider, - }, - }), - - (Story, { globals }) => { - // FIXME: useScope has no effect - useScope({ - settings: { - ...settings, - overallTheme: globals.theme === 'default-' ? '' : globals.theme, - }, - permissions, - }) - - return - }, - ], -} - -const MAX_DOC_LENGTH = 2 * 1024 * 1024 // ol-maxDocLength - -const mockDoc = (content: string, changes: Array> = []) => { - const mockShareJSDoc = { - getText() { - return content - }, - on() { - // do nothing - }, - insert() { - // do nothing - }, - del() { - // do nothing - }, - emit: (...args: any[]) => { - console.log(...args) - }, - } - - return { - doc_id: 'story-doc', - getSnapshot: () => { - return content - }, - attachToCM6: (cm6: any) => { - cm6.attachShareJs(mockShareJSDoc, MAX_DOC_LENGTH) - }, - detachFromCM6: () => { - // Do nothing - }, - getType: () => { - return 'sharejs-text-ot' - }, - on: () => { - // Do nothing - }, - off: () => { - // Do nothing - }, - setTrackChangesIdSeeds: () => { - // Do nothing - }, - setTrackChangesUserId: () => { - // Do nothing - }, - getTrackingChanges: () => { - return true - }, - getInflightOp: () => { - return null - }, - getPendingOp: () => { - return null - }, - ranges: new RangesTracker(changes, []), - hasBufferedOps: () => false, - } -} - -const changes: Record>> = { - tex: [ - { - id: '1', - op: { - i: 'Your introduction goes here! Simply start writing your document and use the Recompile button to view the updated PDF preview. Examples of commonly used commands and features are listed below, to help you get started.', - p: 583, - }, - meta: { - user_id: '1', - ts: new Date().toString(), - }, - }, - ], - md: [], - bib: [], -} - -const content = { - tex: `\\documentclass{article} - -% Language setting -% Replace \`english' with e.g. \`spanish' to change the document language -\\usepackage[english]{babel} - -% Set page size and margins -% Replace \`letterpaper' with \`a4paper' for UK/EU standard size -\\usepackage[letterpaper,top=2cm,bottom=2cm,left=3cm,right=3cm,marginparwidth=1.75cm]{geometry} - -% Useful packages -\\usepackage{amsmath} -\\usepackage{graphicx} -\\usepackage[colorlinks=true, allcolors=blue]{hyperref} - -\\title{Your Paper} -\\author{You} - -\\begin{document} -\\maketitle - -\\begin{abstract} -Your abstract. -\\end{abstract} - -\\section{Introduction} - -Your introduction goes here! Simply start writing your document and use the Recompile button to view the updated PDF preview. Examples of commonly used commands and features are listed below, to help you get started. - -Once you're familiar with the editor, you can find various project settings in the Overleaf menu, accessed via the button in the very top left of the editor. To view tutorials, user guides, and further documentation, please visit our \\href{https://www.overleaf.com/learn}{help library}, or head to our plans page to \\href{https://www.overleaf.com/user/subscription/plans}{choose your plan}. - -\\section{Some examples to get started} - -\\subsection{How to create Sections and Subsections} - -Simply use the section and subsection commands, as in this example document! With Overleaf, all the formatting and numbering is handled automatically according to the template you've chosen. If you're using Rich Text mode, you can also create new section and subsections via the buttons in the editor toolbar. - -\\subsection{How to include Figures} - -First you have to upload the image file from your computer using the upload link in the file-tree menu. Then use the includegraphics command to include it in your document. Use the figure environment and the caption command to add a number and a caption to your figure. See the code for Figure \\ref{fig:frog} in this section for an example. - -Note that your figure will automatically be placed in the most appropriate place for it, given the surrounding text and taking into account other figures or tables that may be close by. You can find out more about adding images to your documents in this help article on \\href{https://www.overleaf.com/learn/how-to/Including_images_on_Overleaf}{including images on Overleaf}. - -\\begin{figure} -\\centering -\\includegraphics[width=0.25\\linewidth]{frog.jpg} -\\caption{This frog was uploaded via the file-tree menu.}\\label{fig:frog} -\\end{figure} - -\\subsection{How to add Tables} - -Use the table and tabular environments for basic tables --- see Table~\\ref{tab:widgets}, for example. For more information, please see this help article on \\href{https://www.overleaf.com/learn/latex/tables}{tables}. - -\\begin{table} -\\centering -\\begin{tabular}{l|r} -Item & Quantity \\\\\\hline -Widgets & 42 \\\\ -Gadgets & 13 -\\end{tabular} -\\caption{\\label{tab:widgets}An example table.} -\\end{table} - -\\subsection{How to add Comments and Track Changes} - -Comments can be added to your project by highlighting some text and clicking \`\`Add comment'' in the top right of the editor pane. To view existing comments, click on the Review menu in the toolbar above. To reply to a comment, click on the Reply button in the lower right corner of the comment. You can close the Review pane by clicking its name on the toolbar when you're done reviewing for the time being. - -Track changes are available on all our \\href{https://www.overleaf.com/user/subscription/plans}{premium plans}, and can be toggled on or off using the option at the top of the Review pane. Track changes allow you to keep track of every change made to the document, along with the person making the change. - -\\subsection{How to add Lists} - -You can make lists with automatic numbering \\dots - -\\begin{enumerate} -\\item Like this, -\\item and like this. -\\end{enumerate} -\\dots or bullet points \\dots -\\begin{itemize} -\\item Like this, -\\item and like this. -\\end{itemize} - -\\subsection{How to write Mathematics} - -\\LaTeX{} is great at typesetting mathematics. Let $X_1, X_2, \\ldots, X_n$ be a sequence of independent and identically distributed random variables with $\\text{E}[X_i] = \\mu$ and $\\text{Var}[X_i] = \\sigma^2 < \\infty$, and let -\\[S_n = \\frac{X_1 + X_2 + \\cdots + X_n}{n} - = \\frac{1}{n}\\sum_{i}^{n} X_i\\] -denote their mean. Then as $n$ approaches infinity, the random variables $\\sqrt{n}(S_n - \\mu)$ converge in distribution to a normal $\\mathcal{N}(0, \\sigma^2)$. - - -\\subsection{How to change the margins and paper size} - -Usually the template you're using will have the page margins and paper size set correctly for that use-case. For example, if you're using a journal article template provided by the journal publisher, that template will be formatted according to their requirements. In these cases, it's best not to alter the margins directly. - -If however you're using a more general template, such as this one, and would like to alter the margins, a common way to do so is via the geometry package. You can find the geometry package loaded in the preamble at the top of this example file, and if you'd like to learn more about how to adjust the settings, please visit this help article on \\href{https://www.overleaf.com/learn/latex/page_size_and_margins}{page size and margins}. - -\\subsection{How to change the document language and spell check settings} - -Overleaf supports many different languages, including multiple different languages within one document. - -To configure the document language, simply edit the option provided to the babel package in the preamble at the top of this example project. To learn more about the different options, please visit this help article on \\href{https://www.overleaf.com/learn/latex/International_language_support}{international language support}. - -To change the spell check language, simply open the Overleaf menu at the top left of the editor window, scroll down to the spell check setting, and adjust accordingly. - -\\subsection{How to add Citations and a References List} - -You can simply upload a \\verb|.bib| file containing your BibTeX entries, created with a tool such as JabRef. You can then cite entries from it, like this: \\cite{greenwade93}. Just remember to specify a bibliography style, as well as the filename of the \\verb|.bib|. You can find a \\href{https://www.overleaf.com/help/97-how-to-include-a-bibliography-using-bibtex}{video tutorial here} to learn more about BibTeX. - -If you have an \\href{https://www.overleaf.com/user/subscription/plans}{upgraded account}, you can also import your Mendeley or Zotero library directly as a \\verb|.bib| file, via the upload menu in the file-tree. - -\\subsection{Good luck!} - -We hope you find Overleaf useful, and do take a look at our \\href{https://www.overleaf.com/learn}{help library} for more tutorials and user guides! Please also let us know if you have any feedback using the Contact Us link at the bottom of the Overleaf menu --- or use the contact form at \\url{https://www.overleaf.com/contact}. - -\\bibliographystyle{alpha} -\\bibliography{sample} - -\\end{document}`, - md: `# Heading - -This is **bold** - -This is _italic_`, - bib: `@book{texbook, - author = {Donald E. Knuth}, - year = {1986}, - title = {The {\\TeX} Book}, - publisher = {Addison-Wesley Professional} -} - -@book{latex:companion, - author = {Frank Mittelbach and Michel Gossens - and Johannes Braams and David Carlisle - and Chris Rowley}, - year = {2004}, - title = {The {\\LaTeX} Companion}, - publisher = {Addison-Wesley Professional}, - edition = {2} -} - -@book{latex2e, - author = {Leslie Lamport}, - year = {1994}, - title = {{\\LaTeX}: a Document Preparation System}, - publisher = {Addison Wesley}, - address = {Massachusetts}, - edition = {2} -} - -@article{knuth:1984, - title={Literate Programming}, - author={Donald E. Knuth}, - journal={The Computer Journal}, - volume={27}, - number={2}, - pages={97--111}, - year={1984}, - publisher={Oxford University Press} -} - -@inproceedings{lesk:1977, - title={Computer Typesetting of Technical Journals on {UNIX}}, - author={Michael Lesk and Brian Kernighan}, - booktitle={Proceedings of American Federation of - Information Processing Societies: 1977 - National Computer Conference}, - pages={879--888}, - year={1977}, - address={Dallas, Texas} -} -`, -} diff --git a/services/web/frontend/stories/subscription/active-subscription.stories.tsx b/services/web/frontend/stories/subscription/active-subscription.stories.tsx deleted file mode 100644 index 06f8d9bd05..0000000000 --- a/services/web/frontend/stories/subscription/active-subscription.stories.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import React from 'react' -import { ActiveSubscription } from '../../js/features/subscription/components/dashboard/states/active/active' -import { - annualActiveSubscription, - groupActiveSubscription, - groupActiveSubscriptionWithPendingLicenseChange, - monthlyActiveCollaborator, - pendingSubscriptionChange, - trialCollaboratorSubscription, - trialSubscription, - pastDueExpiredSubscription, - annualActiveSubscriptionEuro, - annualActiveSubscriptionWithAddons, - annualActiveSubscriptionWithCoupons, - pendingPausedSubscription, - groupProfessionalActiveSubscription, -} from '../../../test/frontend/features/subscription/fixtures/subscriptions' -import { SubscriptionDashboardProvider } from '../../js/features/subscription/context/subscription-dashboard-context' -import { SplitTestProvider } from '@/shared/context/split-test-context' -import { PaidSubscription } from '@ol-types/subscription/dashboard/subscription' -import type { StoryFn } from '@storybook/react-webpack5' -import { setupSubscriptionDashContext } from '../../../test/frontend/features/subscription/helpers/setup-subscription-dash-context' - -export default { - title: 'Subscription/ActiveSubscription', - component: ActiveSubscription, - argTypes: { - subscription: { control: 'object' }, - canUseFlexibleLicensing: { control: 'boolean' }, - }, -} - -const Template: StoryFn<{ - subscription: PaidSubscription - canUseFlexibleLicensing: boolean -}> = args => { - window.metaAttributesCache = window.metaAttributesCache || new Map() - // @ts-ignore - delete global.recurly - setupSubscriptionDashContext({ - metaTags: [ - { name: 'ol-subscription', value: args.subscription }, - { - name: 'ol-canUseFlexibleLicensing', - value: args.canUseFlexibleLicensing, - }, - ], - currencyCode: args.subscription.payment.currency, - recurlyNotLoaded: false, - queryingRecurly: false, - }) - return ( - - - - - - ) -} - -export const CollaboratorAnnual = Template.bind({}) -CollaboratorAnnual.args = { - subscription: annualActiveSubscription, - canUseFlexibleLicensing: - annualActiveSubscription.plan?.canUseFlexibleLicensing, -} - -export const CollaboratorMonthly = Template.bind({}) -CollaboratorMonthly.args = { - subscription: monthlyActiveCollaborator, - canUseFlexibleLicensing: - monthlyActiveCollaborator.plan?.canUseFlexibleLicensing, -} - -export const CollaboratorAnnualEuro = Template.bind({}) -CollaboratorAnnualEuro.args = { - subscription: annualActiveSubscriptionEuro, - canUseFlexibleLicensing: - annualActiveSubscriptionEuro.plan?.canUseFlexibleLicensing, -} - -export const CollaboratorTrial = Template.bind({}) -CollaboratorTrial.args = { - subscription: trialCollaboratorSubscription, - canUseFlexibleLicensing: - trialCollaboratorSubscription.plan?.canUseFlexibleLicensing, -} - -export const PersonalTrial = Template.bind({}) -PersonalTrial.args = { - subscription: trialSubscription, - canUseFlexibleLicensing: trialSubscription.plan?.canUseFlexibleLicensing, -} - -export const GroupStandard = Template.bind({}) -GroupStandard.args = { - subscription: groupActiveSubscription, - canUseFlexibleLicensing: - groupActiveSubscription.plan?.canUseFlexibleLicensing, -} - -export const GroupProfessional = Template.bind({}) -GroupProfessional.args = { - subscription: groupProfessionalActiveSubscription, - canUseFlexibleLicensing: - groupProfessionalActiveSubscription.plan?.canUseFlexibleLicensing, -} - -export const GroupPendingLicenseChange = Template.bind({}) -GroupPendingLicenseChange.args = { - subscription: groupActiveSubscriptionWithPendingLicenseChange, - canUseFlexibleLicensing: - groupActiveSubscriptionWithPendingLicenseChange.plan - ?.canUseFlexibleLicensing, -} - -export const PastDueExpired = Template.bind({}) -PastDueExpired.args = { - subscription: pastDueExpiredSubscription, - canUseFlexibleLicensing: - pastDueExpiredSubscription.plan?.canUseFlexibleLicensing, -} - -export const Addons = Template.bind({}) -Addons.args = { - subscription: annualActiveSubscriptionWithAddons, - canUseFlexibleLicensing: - annualActiveSubscriptionWithAddons.plan?.canUseFlexibleLicensing, -} - -export const PendingPaused = Template.bind({}) -PendingPaused.args = { - subscription: pendingPausedSubscription, - canUseFlexibleLicensing: - pendingPausedSubscription.plan?.canUseFlexibleLicensing, -} - -export const PendingPlanChange = Template.bind({}) -PendingPlanChange.args = { - subscription: pendingSubscriptionChange, - canUseFlexibleLicensing: - pendingSubscriptionChange.plan?.canUseFlexibleLicensing, -} - -export const Coupons = Template.bind({}) -Coupons.args = { - subscription: annualActiveSubscriptionWithCoupons, - canUseFlexibleLicensing: - annualActiveSubscriptionWithCoupons.plan?.canUseFlexibleLicensing, -} diff --git a/services/web/frontend/stories/subscription/group-invites/group-invites.stories.tsx b/services/web/frontend/stories/subscription/group-invites/group-invites.stories.tsx deleted file mode 100644 index 1021f770e9..0000000000 --- a/services/web/frontend/stories/subscription/group-invites/group-invites.stories.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import GroupInvites from '@/features/subscription/components/group-invites/group-invites' -import type { TeamInvite } from '../../../../types/team-invite' -import { useMeta } from '../../hooks/use-meta' -import { ScopeDecorator } from '../../decorators/scope' - -export const GroupInvitesDefault = () => { - const teamInvites: TeamInvite[] = [ - { - email: 'email1@exammple.com', - token: 'token123', - inviterName: 'inviter1@example.com', - sentAt: new Date(), - _id: '123abc', - }, - { - email: 'email2@exammple.com', - token: 'token456', - inviterName: 'inviter2@example.com', - sentAt: new Date(), - _id: '456bcd', - }, - ] - - useMeta({ 'ol-teamInvites': teamInvites }) - - return ( -
    - -
    - ) -} - -export default { - title: 'Subscription / Group Invites', - component: GroupInvites, - args: { - show: true, - }, - argTypes: { - handleHide: { action: 'close modal' }, - onDisableSSO: { action: 'callback' }, - }, - decorators: [ScopeDecorator], -} diff --git a/services/web/frontend/stories/use-expand-collapse.stories.jsx b/services/web/frontend/stories/use-expand-collapse.stories.jsx deleted file mode 100644 index 8c5000b063..0000000000 --- a/services/web/frontend/stories/use-expand-collapse.stories.jsx +++ /dev/null @@ -1,135 +0,0 @@ -import PropTypes from 'prop-types' -import useExpandCollapse from '../js/shared/hooks/use-expand-collapse' - -function TestUI({ children, expandableProps, toggleProps }) { - return ( - <> -
    {children}
    - - - ) -} - -function VerticalTestUI(props) { - return {verticalContents} -} - -function HorizontalTestUI(props) { - return {horizontalContents} -} - -VerticalTestUI.propTypes = { - expandableProps: PropTypes.object, - toggleProps: PropTypes.object, -} - -HorizontalTestUI.propTypes = VerticalTestUI.propTypes - -TestUI.propTypes = { - ...VerticalTestUI.propTypes, - children: PropTypes.node, -} - -export const Vertical = args => { - const { expandableProps, toggleProps } = useExpandCollapse(args) - return ( - - ) -} - -export const VerticalInitiallyExpanded = args => { - const { expandableProps, toggleProps } = useExpandCollapse(args) - return ( - - ) -} -VerticalInitiallyExpanded.args = { - initiallyExpanded: true, -} - -export const VerticalWithMinSize = args => { - const { expandableProps, toggleProps } = useExpandCollapse(args) - return ( - - ) -} -VerticalWithMinSize.args = { - collapsedSize: 200, -} - -export const Horizontal = args => { - const { expandableProps, toggleProps } = useExpandCollapse(args) - return ( - - ) -} -Horizontal.args = { - dimension: 'width', -} - -export const HorizontalInitiallyExpanded = args => { - const { expandableProps, toggleProps } = useExpandCollapse(args) - return ( - - ) -} -HorizontalInitiallyExpanded.args = { - initiallyExpanded: true, -} - -export const HorizontalWithMinSize = args => { - const { expandableProps, toggleProps } = useExpandCollapse(args) - return ( - - ) -} -HorizontalWithMinSize.args = { - dimension: 'width', - collapsedSize: 200, -} - -const defaultContentStyles = { - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - fontSize: '2em', - backgroundImage: 'linear-gradient(to bottom, red, blue)', - width: '500px', - height: '500px', - color: '#FFF', -} - -const verticalContents =
    Vertical
    - -const horizontalContents = ( -
    - Horizontal -
    -) - -export default { - title: 'Shared / Hooks / useExpandCollapse', -} diff --git a/services/web/frontend/stories/utils/arg-types.ts b/services/web/frontend/stories/utils/arg-types.ts deleted file mode 100644 index e14aa11444..0000000000 --- a/services/web/frontend/stories/utils/arg-types.ts +++ /dev/null @@ -1,12 +0,0 @@ -export const disableControlsOf = (...args: string[]) => { - return args.reduce>((prev, cur) => { - return { - ...prev, - [cur]: { - table: { - disable: true, - }, - }, - } - }, {}) -} diff --git a/services/web/frontend/stories/word-count-modal.stories.tsx b/services/web/frontend/stories/word-count-modal.stories.tsx deleted file mode 100644 index e9c7e9c3d9..0000000000 --- a/services/web/frontend/stories/word-count-modal.stories.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import WordCountModal from '@/features/word-count-modal/components/word-count-modal' -import { ScopeDecorator } from './decorators/scope' -import useFetchMock from './hooks/use-fetch-mock' -import preview from '@ol-storybook/preview' - -const meta = preview.meta({ - title: 'Editor / Modals / Word Count', - component: WordCountModal, - args: { - show: true, - }, - argTypes: { - handleHide: { - action: 'close modal', - }, - }, - decorators: [Story => ScopeDecorator(Story)], -}) - -const counts = { - headers: 4, - mathDisplay: 40, - mathInline: 400, - textWords: 4000, -} - -const messages = [ - 'Lorem ipsum dolor sit amet.', - 'Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.', -].join('\n') - -export const WordCount = meta.story({ - decorators: [ - Story => { - useFetchMock(fetchMock => { - fetchMock.get( - 'express:/project/:projectId/wordcount', - { status: 200, body: { texcount: counts } }, - { delay: 500 } - ) - }) - - return - }, - ], -}) - -export const WordCountWithMessages = meta.story({ - decorators: [ - Story => { - useFetchMock(fetchMock => { - fetchMock.get( - 'express:/project/:projectId/wordcount', - { status: 200, body: { texcount: { ...counts, messages } } }, - { delay: 500 } - ) - }) - - return - }, - ], -}) - -export const ErrorResponse = meta.story({ - decorators: [ - Story => { - useFetchMock(fetchMock => { - fetchMock.get( - 'express:/project/:projectId/wordcount', - { status: 500 }, - { delay: 500 } - ) - }) - - return - }, - ], -}) diff --git a/services/web/i18next-scanner.config.js b/services/web/i18next-scanner.config.js deleted file mode 100644 index c21c6c537f..0000000000 --- a/services/web/i18next-scanner.config.js +++ /dev/null @@ -1,50 +0,0 @@ -const fs = require('node:fs') -const path = require('node:path') -const typescript = require('typescript') - -module.exports = { - input: [ - 'frontend/js/**/*.{js,jsx,ts,tsx}', - 'modules/*/frontend/js/**/*.{js,jsx,ts,tsx}', - '!frontend/js/vendor/**', - '!modules/writefull/frontend/js/integration/**', - ], - output: './', - options: { - sort: true, - func: { - list: ['t', 'phrase'], - extensions: ['.js', '.jsx'], - }, - trans: { - component: 'Trans', - i18nKey: 'i18nKey', - defaultsKey: 'defaults', - extensions: ['.js', '.jsx'], - fallbackKey: false, - }, - resource: { - savePath: 'frontend/extracted-translations.json', - jsonIndent: 2, - lineEnding: '\n', - }, - }, - // adapted from https://github.com/nucleartux/i18next-scanner-typescript/blob/master/src/index.js - transform: function (file, enc, done) { - const { base, ext } = path.parse(file.path) - - if (['.ts', '.tsx'].includes(ext) && !base.endsWith('.d.ts')) { - const content = fs.readFileSync(file.path, enc) - - const { outputText } = typescript.transpileModule(content, { - compilerOptions: { target: 'es2018', jsx: 'preserve' }, - fileName: base, - }) - - this.parser.parseTransFromString(outputText) - this.parser.parseFuncFromString(outputText) - } - - done() - }, -} diff --git a/services/web/knip.ts b/services/web/knip.ts deleted file mode 100644 index 67ea064a2f..0000000000 --- a/services/web/knip.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Configuration for knip, a tool to find unused exports in a project. - * - * To run knip, use the command: bin/npm -w services/web run knip - * - * It currently only runs in the frontend web service, but could be - * adapted to run in the backend as well. - * - * This is an initial version built in a hackathon. It is useful but not yet complete. - * In future we should add it to our CI checks but we need to make it more robust first. - */ - -import type { KnipConfig } from 'knip' -import Path from 'path' -import settings from './config/settings.webpack' - -const moduleEntryPoints = Object.values(settings.overleafModuleImports).flatMap( - paths => - paths.map(path => { - const cleanedPath = path.replace(Path.join(__dirname, '/'), '') - if (cleanedPath.match(/\.(js|jsx|ts|tsx?)$/)) { - return cleanedPath - } - - return `${cleanedPath}.{js,jsx,ts,tsx}` - }) -) - -const knipConfig: KnipConfig = { - $schema: 'https://unpkg.com/knip@5/schema.json', - entry: [ - 'frontend/js/pages/**/*.{js,jsx,ts,tsx}', - 'frontend/stories/**/*.{js,jsx,ts,tsx}', - 'test/frontend/**/*.{js,jsx,ts,tsx}', - 'modules/**/test/**/*.{js,jsx,ts,tsx}', - 'modules/**/*.test.{js,jsx,ts,tsx}', - 'modules/**/stories/**/*.{js,jsx,ts,tsx}', - // TODO: update this when I work out how writefull entry points work - 'modules/writefull/**/*.{js,jsx,ts,tsx}', - 'types/window.ts', - // Workers are loaded dynamically so we explicitly include all workers - // here until we work out a way to follow the dynamic imports - 'modules/**/*.worker*', - 'frontend/js/**/*.worker*', - ...moduleEntryPoints, - ], - project: [ - 'frontend/js/**/*.{js,jsx,ts,tsx}', - 'modules/**/frontend/**/*.{js,jsx,ts,tsx}', - '!frontend/js/.storybook/**/*.{js,jsx,ts,tsx}', - ], - ignore: [ - // TODO: Files that we should keep around even when unused. - // I would like to do this in the file itself but I can't seem - // to work out a way to do that (@knipignore only works for - // individual exports rather than whole files) - 'frontend/js/shared/components/labs/labs-experiments-widget.tsx', - 'frontend/js/shared/components/tooltip-promo.tsx', - ], - ignoreExportsUsedInFile: true, - ignoreBinaries: ['.*'], - ignoreDependencies: ['.*'], - tags: ['-knipignore'], -} - -export default knipConfig diff --git a/services/web/local-dev.env b/services/web/local-dev.env deleted file mode 100644 index 8d15c3852f..0000000000 --- a/services/web/local-dev.env +++ /dev/null @@ -1,19 +0,0 @@ -WEBPACK_HOST=127.0.0.1 -REDIS_HOST=127.0.0.1 -QUEUES_REDIS_HOST=127.0.0.1 -ANALYTICS_QUEUES_REDIS_HOST=127.0.0.1 -PUBSUB_REDIS_HOST=127.0.0.1 -RATELIMITER_REDIS_HOST=127.0.0.1 -GCLOUD_2_REDIS_HOST=127.0.0.1 -POSTGRES_HOST=127.0.0.1 -MONGO_URL=mongodb://127.0.0.1/sharelatex?directConnection=true -OVERLEAF_LDAP_URL=ldap://127.0.0.1:22389 -SAML_BASE_URL_PATH=http://127.0.0.1:22280/simplesaml/ -OVERLEAF_SAML_ENTRYPOINT=http://127.0.0.1:22280/simplesaml/saml2/idp/SSOService.php -OVERLEAF_SAML_CALLBACK_URL=http://www.dev-overleaf.com/saml/callback -COOKIE_DOMAIN=.dev-overleaf.com -PUBLIC_URL=https://www.dev-overleaf.com -LINKED_URL_PROXY=http://127.0.0.1:8081/proxy/secret-proxy-token -OVERLEAF_CONFIG=$(pwd)/../../config/web/settings.web.js -ENABLE_PDF_CACHING=true -NODE_ENV=development diff --git a/services/web/local-test.env b/services/web/local-test.env deleted file mode 100644 index 6acb592d1e..0000000000 --- a/services/web/local-test.env +++ /dev/null @@ -1,19 +0,0 @@ -CLEANUP_MONGO=true - -REDIS_HOST=127.0.0.1 -REDIS_DB=5 -QUEUES_REDIS_HOST=127.0.0.1 -DSMP_REDIS_HOST=127.0.0.1 -PUBSUB_REDIS_HOST=127.0.0.1 -RATELIMITER_REDIS_HOST=127.0.0.1 -GCLOUD_2_REDIS_HOST=127.0.0.1 -POSTGRES_HOST=127.0.0.1 -MONGO_URL=mongodb://127.0.0.1/test-overleaf?directConnection=true -OVERLEAF_LDAP_URL=ldap://127.0.0.1:22389 -SAML_BASE_URL_PATH=http://127.0.0.1:22280/simplesaml/ -OVERLEAF_SAML_ENTRYPOINT=http://127.0.0.1:22280/simplesaml/saml2/idp/SSOService.php -OVERLEAF_SAML_CALLBACK_URL=http://127.0.0.1:22280/saml/callback - -COOKIE_DOMAIN= -PUBLIC_URL=http://127.0.0.1:23000 -HTTP_TEST_HOST=127.0.0.1 diff --git a/services/web/scripts/add_feature_override.mjs b/services/web/scripts/add_feature_override.mjs deleted file mode 100644 index 136ce2aa24..0000000000 --- a/services/web/scripts/add_feature_override.mjs +++ /dev/null @@ -1,182 +0,0 @@ -// Script to add feature overrides -// -// A feature override is appended to the user's featuresOverride list if they do -// not already have the feature. The features are refreshed after adding the -// override. -// -// If the script detects that the user would have the feature just by refreshing -// then it skips adding the override and just refreshes the users features -- -// this is to minimise the creation of unnecessary overrides. -// -// Usage: -// -// $ node scripts/add_feature_override.mjs --commit --note 'text description' --expires 2022-01-01 --override JSONFILE --ids IDFILE -// -// --commit do the update, remove this option for dry-run testing -// --note text description [optional] -// --expires expiry date for override [optional] -// --skip-existing don't create the override for users who already have the feature (e.g. via a subscription) -// -// IDFILE: file containing list of user ids, one per line -// JSONFILE: file containing JSON of the desired feature overrides e.g. {"symbolPalette": true} -// -// The feature override is specified with JSON to allow types to be set as string/number/boolean. -// It is contained in a file to avoid any issues with shell quoting. - -import minimist from 'minimist' - -import fs from 'node:fs' -import { ObjectId } from '../app/src/infrastructure/mongodb.mjs' -import pLimit from 'p-limit' -import FeaturesUpdater from '../app/src/Features/Subscription/FeaturesUpdater.mjs' -import FeaturesHelper from '../app/src/Features/Subscription/FeaturesHelper.mjs' -import UserFeaturesUpdater from '../app/src/Features/Subscription/UserFeaturesUpdater.mjs' -import UserGetter from '../app/src/Features/User/UserGetter.mjs' - -const processLogger = { - failed: [], - success: [], - skipped: [], - printSummary: () => { - console.log( - { - success: processLogger.success, - failed: processLogger.failed, - skipped: processLogger.skipped, - }, - `\nDONE. ${processLogger.success.length} successful. ${processLogger.skipped.length} skipped. ${processLogger.failed.length} failed to update.` - ) - }, -} - -function _validateUserIdList(userIds) { - userIds.forEach(userId => { - if (!ObjectId.isValid(userId)) - throw new Error(`user ID not valid: ${userId}`) - }) -} - -async function _handleUser(userId) { - console.log('updating user', userId) - const user = await UserGetter.promises.getUser(userId, { - features: 1, - featuresOverrides: 1, - }) - if (!user) { - console.log(userId, 'does not exist, failed') - processLogger.failed.push(userId) - return - } - const desiredFeatures = OVERRIDE.features - // Does the user have the requested features already? - if ( - SKIP_EXISTING && - FeaturesHelper.isFeatureSetBetter(user.features, desiredFeatures) - ) { - console.log( - userId, - `already has ${JSON.stringify(desiredFeatures)}, skipping` - ) - processLogger.skipped.push(userId) - return - } - // Would the user have the requested feature if the features were refreshed? - const freshFeatures = await FeaturesUpdater.promises.computeFeatures(userId) - if ( - SKIP_EXISTING && - FeaturesHelper.isFeatureSetBetter(freshFeatures, desiredFeatures) - ) { - console.log( - userId, - `would have ${JSON.stringify( - desiredFeatures - )} if refreshed, skipping override` - ) - } else { - // create the override (if not in dry-run mode) - if (COMMIT) { - await UserFeaturesUpdater.promises.createFeaturesOverride( - userId, - OVERRIDE - ) - } - } - if (!COMMIT) { - // not saving features; nothing else to do - return - } - const refreshResult = await FeaturesUpdater.promises.refreshFeatures( - userId, - 'add-feature-override-script' - ) - const featureSetIncludesNewFeatures = FeaturesHelper.isFeatureSetBetter( - refreshResult.features, - desiredFeatures - ) - if (featureSetIncludesNewFeatures) { - // features added successfully - processLogger.success.push(userId) - } else { - console.log('FEATURE NOT ADDED', refreshResult) - processLogger.failed.push(userId) - } -} - -const argv = minimist(process.argv.slice(2)) -const CONCURRENCY = argv.async ? argv.async : 10 -const overridesFilename = argv.override -const expires = argv.expires -const note = argv.note -const SKIP_EXISTING = argv['skip-existing'] || false -const COMMIT = argv.commit !== undefined -if (!COMMIT) { - console.warn('Doing dry run without --commit') -} - -const idsFilename = argv.ids -if (!idsFilename) throw new Error('missing ids list filename') - -const usersFile = fs.readFileSync(idsFilename, 'utf8') -const userIds = usersFile - .trim() - .split('\n') - .map(id => id.trim()) - -const overridesFile = fs.readFileSync(overridesFilename, 'utf8') -const features = JSON.parse(overridesFile) -const OVERRIDE = { features } -if (note) { - OVERRIDE.note = note -} -if (expires) { - OVERRIDE.expiresAt = new Date(expires) -} - -async function processUsers(userIds) { - console.log('---Starting add feature override script---') - - console.log('Will update users to have', OVERRIDE) - console.log( - SKIP_EXISTING - ? 'Users with this feature already will be skipped' - : 'Every user in file will get feature override' - ) - - _validateUserIdList(userIds) - console.log(`---Starting to process ${userIds.length} users---`) - - const limit = pLimit(CONCURRENCY) - const results = await Promise.allSettled( - userIds.map(userId => limit(() => _handleUser(new ObjectId(userId)))) - ) - results.forEach((result, idx) => { - if (result.status !== 'fulfilled') { - console.log(userIds[idx], 'failed', result.reason) - processLogger.failed.push(userIds[idx]) - } - }) - processLogger.printSummary() - process.exit() -} - -await processUsers(userIds) diff --git a/services/web/scripts/add_marketing_details_to_csv.mjs b/services/web/scripts/add_marketing_details_to_csv.mjs deleted file mode 100644 index 4d29a99787..0000000000 --- a/services/web/scripts/add_marketing_details_to_csv.mjs +++ /dev/null @@ -1,165 +0,0 @@ -#!/usr/bin/env node -/* eslint-disable camelcase */ - -import { scriptRunner } from './lib/ScriptRunner.mjs' -import fs from 'node:fs' -import minimist from 'minimist' -import { - db, - ObjectId, - READ_PREFERENCE_SECONDARY, -} from '../app/src/infrastructure/mongodb.mjs' -// https://github.com/import-js/eslint-plugin-import/issues/1810 -// eslint-disable-next-line import/no-unresolved -import * as csv from 'csv/sync' - -function usage() { - console.log( - ` - This script enriches price and subscription data with user emails and first names, given a csv containing user IDs, outputs to /tmp/output.csv - - Usage: - - Locally: - docker compose exec web bash - node scripts/add_marketing_details_to_csv.mjs [--input=] [--output=] [--batchSize=] - - On the server: - rake run:pod[staging,web] - node scripts/add_marketing_details_to_csv.mjs [--input=] [--output=] [--batchSize=] - exit - kubectl cp web-standalone-prod-XXXXX:/tmp/output.csv ~/output.csv - - Options: - --help Show this screen - - --input= Input file path (default: input.csv) - - --output= Output file path (default: /tmp/output.csv) - - --batchSize= Number of users to be fetched in one query - ` - ) -} - -function parseArgs() { - const result = minimist(process.argv.slice(2), { - string: ['input', 'output'], - bool: ['help'], - number: ['batchSize'], - default: { - help: false, - input: 'input.csv', - output: '/tmp/output.csv', - batchSize: 1000, - }, - }) - - if (result.help) { - usage() - process.exit(0) - } - - return result -} - -async function processBatch(trackProgress, idBatch) { - const result = {} - try { - const cursor = db.users.find( - { - _id: { $in: idBatch }, - }, - { - projection: { - _id: 1, - first_name: 1, - email: 1, - }, - readPreference: READ_PREFERENCE_SECONDARY, - } - ) - - for await (const doc of cursor) { - result[doc._id.toString()] = { - email: doc.email, - first_name: doc.first_name, - } - } - } catch (err) { - await trackProgress(`ERROR Processing batch: ${err}`) - } - return result -} - -async function enrichRecords(trackProgress, records, users) { - const result = [] - - for (const record of records) { - const user = users[record.user_id] - let email = '' - let first_name = '' - if (user) { - if (user.email === '' || user.first_name === '') { - await trackProgress(`WARNING Incomplete data for: ${record.user_id}`) - } - email = user.email - first_name = user.first_name - } else { - await trackProgress(`WARNING Didn't find: ${record.user_id}`) - } - result.push({ - ...record, - email, - first_name, - }) - } - - return result -} - -async function main(trackProgress) { - const args = parseArgs() - const input = fs.readFileSync(args.input, 'utf8') - const records = csv.parse(input, { columns: true, skipEmptyLines: true }) - await trackProgress(`INFO Starting to process ${records.length} records`) - - let objectIdBatch = [] - let recordBatch = [] - const outputRecords = [] - for (const record of records) { - try { - objectIdBatch.push(new ObjectId(record.user_id)) - recordBatch.push(record) - } catch (e) { - await trackProgress(`ERROR Skipping invalid user ID: ${record.user_id}`) - outputRecords.push({ ...record, email: '', first_name: '' }) - } - if (objectIdBatch.length >= args.batchSize) { - const users = await processBatch(trackProgress, objectIdBatch) - const enriched = await enrichRecords(trackProgress, recordBatch, users) - outputRecords.push(...enriched) - - objectIdBatch = [] - recordBatch = [] - } - } - - if (objectIdBatch.length > 0) { - const users = await processBatch(trackProgress, objectIdBatch) - const enriched = await enrichRecords(trackProgress, recordBatch, users) - outputRecords.push(...enriched) - } - - const output = csv.stringify(outputRecords, { header: true }) - fs.writeFileSync(args.output, output) - await trackProgress( - `INFO Finished processing of ${outputRecords.length} records` - ) -} - -try { - await scriptRunner(main) - process.exit(0) -} catch (error) { - console.error(error) - process.exit(1) -} diff --git a/services/web/scripts/add_salesforce_data_to_subscriptions.mjs b/services/web/scripts/add_salesforce_data_to_subscriptions.mjs deleted file mode 100755 index e7fb4ee238..0000000000 --- a/services/web/scripts/add_salesforce_data_to_subscriptions.mjs +++ /dev/null @@ -1,207 +0,0 @@ -import fs from 'node:fs' -import minimist from 'minimist' -import { parse } from 'csv' -import Stream from 'node:stream/promises' -import { ObjectId } from '../app/src/infrastructure/mongodb.mjs' -import { Subscription } from '../app/src/models/Subscription.mjs' -import { scriptRunner } from './lib/ScriptRunner.mjs' - -function usage() { - console.log( - 'Usage: node add_salesforce_data_to_subscriptions.mjs -f [options]' - ) - console.log( - 'Updates the subscriptions collection with external IDs for determining the Salesforce account that goes with the subscription. The file should be a CSV and have columns account_id, v1_id and subscription_id. The account_id column is the Salesforce account ID, the v1_id column is the V1 account ID, and the subscription_id column is the subscription ID.' - ) - console.log('Options:') - console.log( - ' --commit, -c Commit changes to the database' - ) - console.log( - ' --emptyFieldValue The value to treat as an empty field (default: NA)' - ) - console.log( - ' -f, --filename The path to the file to read data from' - ) - console.log(' -h, --help Show this help message') - console.log(' -v, --verbose Produces more detailed logs') - process.exit(0) -} - -const { commit, emptyFieldValue, filename, help, verbose } = minimist( - process.argv.slice(2), - { - string: ['emptyFieldValue', 'filename'], - boolean: ['commit', 'help', 'verbose'], - alias: { - commit: 'c', - filename: 'f', - help: 'h', - verbose: 'v', - }, - default: { - commit: false, - emptyFieldValue: 'NA', - help: false, - verbose: false, - }, - } -) - -const SUBSCRIPTION_ID_FIELD = 'subscription_id' -const SALESFORCE_ID_FIELD = 'account_id' -const V1_ID_FIELD = 'v1_id' - -if (help) { - usage() - process.exit(0) -} - -if (!filename) { - console.error('No filename provided') - usage() - process.exit(1) -} - -const stats = { - totalRows: 0, - subscriptionIDMissing: 0, - usedV1ID: 0, - usedSalesforceID: 0, - processedRows: 0, - db: { - errors: 0, - matched: 0, - updateAttempted: 0, - updated: 0, - }, -} - -function generateStats() { - return `Stats: - Total rows: ${stats.totalRows} - Processed rows: ${stats.processedRows} - Skipped (no subscription ID): ${stats.subscriptionIDMissing} - Used V1 ID: ${stats.usedV1ID} - Used Salesforce ID: ${stats.usedSalesforceID}${ - commit - ? ` -Database operations: - Errors: ${stats.db.errors} - Matched: ${stats.db.matched} - Updated: ${stats.db.updated} - Update attempted: ${stats.db.updateAttempted}` - : '' - }` -} - -function pickRelevantColumns(row) { - const newRow = { - salesforceId: row[SALESFORCE_ID_FIELD], - } - - if (row[V1_ID_FIELD] && row[V1_ID_FIELD] !== emptyFieldValue) { - newRow.v1Id = row[V1_ID_FIELD] - } - - if ( - row[SUBSCRIPTION_ID_FIELD] && - row[SUBSCRIPTION_ID_FIELD] !== emptyFieldValue - ) { - newRow.subscriptionId = row[SUBSCRIPTION_ID_FIELD] - } - - return newRow -} - -async function processRows(rows) { - for await (const row of rows) { - const { v1Id, salesforceId, subscriptionId } = row - - const update = {} - if (v1Id) { - stats.usedV1ID++ - update.v1_id = v1Id - } else { - stats.usedSalesforceID++ - update.salesforce_id = salesforceId - } - - // Useful for logging later. - const updateString = Object.entries(update).flatMap(([k, v]) => `${k}=${v}`) - - if (commit) { - try { - const result = await Subscription.updateOne( - { _id: new ObjectId(subscriptionId) }, - update, - { upsert: false } - ) - if (result.matchedCount) { - stats.db.matched++ - } - if (result.modifiedCount) { - stats.db.updated++ - if (verbose) { - console.log( - `Updated subscription ${subscriptionId} to set ${updateString}` - ) - } - } - } catch (error) { - stats.db.errors++ - if (verbose) { - console.error( - `Error updating subscription ${subscriptionId}: ${error}` - ) - } - } finally { - stats.db.updateAttempted++ - } - } else if (verbose) { - console.log(`Would set ${updateString} on subscription ${subscriptionId}`) - } - } -} - -async function main(trackProgress) { - await Stream.pipeline( - fs.createReadStream(filename), - parse({ - columns: true, - cast: function (value, context) { - if (context.column === V1_ID_FIELD && value !== emptyFieldValue) { - return parseInt(value) - } - return value - }, - on_record: function (record, context) { - stats.totalRows++ - const row = pickRelevantColumns(record) - // Cannot process records without a Subscription ID - if (!row.subscriptionId) { - if (verbose) { - console.log( - `No subscription id found for ${row.salesforceId}, skipping...` - ) - } - stats.subscriptionIDMissing++ - return null - } - stats.processedRows++ - return row - }, - }), - processRows - ) - await trackProgress(generateStats()) -} - -if (!commit) { - console.log('Dry run') -} else { - console.log('Committing changes to the database') -} - -await scriptRunner(main) -process.exit() diff --git a/services/web/scripts/add_subscription_members_csv.mjs b/services/web/scripts/add_subscription_members_csv.mjs deleted file mode 100644 index 25a29bed0d..0000000000 --- a/services/web/scripts/add_subscription_members_csv.mjs +++ /dev/null @@ -1,202 +0,0 @@ -import fs from 'node:fs' -import minimist from 'minimist' -import { parse } from 'csv' -import Stream from 'node:stream/promises' -import SubscriptionGroupHandler from '../app/src/Features/Subscription/SubscriptionGroupHandler.mjs' -import { Subscription } from '../app/src/models/Subscription.mjs' -import { InvalidEmailError } from '../app/src/Features/Errors/Errors.js' - -function usage() { - console.log( - 'Usage: node scripts/add_subscription_members_csv.mjs -f -i -s [options]' - ) - console.log('Required arguments:') - console.log( - ' -s, --subscriptionId The ID of the subscription to update' - ) - console.log( - ' -i, --inviterId The ID of the user sending the invites' - ) - console.log( - ' -f, --filename The path to the file to read data from' - ) - console.log('Options:') - console.log( - ' --commit, -c Whether changes should be committed to the DB invites should be sent/revoked' - ) - console.log( - ' --removeMembersNotIncluded -r Remove members that are not in the CSV. Disabled when managed users are enabled for the subscription' - ) - console.log( - ' --verbose, -v Prints detailed information about the affected group members' - ) - console.log(' -h, --help Show this help message') - process.exit(0) -} - -let { - commit, - removeMembersNotIncluded, - inviterId, - subscriptionId, - filename, - help, - verbose, -} = minimist(process.argv.slice(2), { - string: ['filename', 'subscriptionId', 'inviterId'], - boolean: ['commit', 'removeMembersNotIncluded', 'help', 'verbose'], - alias: { - commit: 'c', - removeMembersNotIncluded: 'r', - filename: 'f', - help: 'h', - inviterId: 'i', - subscriptionId: 's', - verbose: 'v', - }, - default: { - commit: false, - removeMembersNotIncluded: false, - help: false, - verbose: false, - }, -}) - -const EMAIL_FIELD = 'email' - -if (help) { - usage() - process.exit(0) -} - -if (!subscriptionId || !inviterId || !filename) { - usage() - process.exit(1) -} - -async function processRows(rows) { - const emailList = [] - for await (const row of rows) { - const email = row[EMAIL_FIELD] - if (email) { - emailList.push(email) - } - } - if (emailList.length === 0) { - console.error(`CSV error: 'email' column doesn't exist or it's empty'`) - process.exit(1) - } - - let previewResult - - try { - previewResult = - await SubscriptionGroupHandler.promises.updateGroupMembersBulk( - inviterId, - subscriptionId, - emailList, - { removeMembersNotIncluded } - ) - } catch (error) { - if (error instanceof InvalidEmailError) { - console.error(`${filename} contains invalid email addresses:`) - console.error(error.info?.invalidEmails.join(',')) - process.exit(1) - } else { - throw error - } - } - - console.log('Result Preview:') - logResult(previewResult) - - if (previewResult.newTotalCount > previewResult.membersLimit) { - console.warn( - 'WARNING: the invite list has reached the membership limit (newTotalCount > membersLimit)' - ) - if (commit) { - console.error(`Invites won't be sent and users won't be deleted`) - } - process.exit(1) - } - - if (!commit) { - console.log( - 'this is a dry-run, use the --commit option to send the invite and make any DB changes' - ) - return - } - - console.log( - `Sending invites to ${previewResult.emailsToSendInvite.length} email addresses` - ) - - if (previewResult.membersToRemove > 0) { - console.log( - `${previewResult.membersToRemove.length} members will be removed from the group` - ) - } - - const commitResult = - await SubscriptionGroupHandler.promises.updateGroupMembersBulk( - inviterId, - subscriptionId, - emailList, - { removeMembersNotIncluded, commit } - ) - - console.log('Result:') - logResult(commitResult) -} - -function logResult(result) { - console.log( - JSON.stringify( - { - ...result, - emailsToSendInvite: verbose - ? result.emailsToSendInvite - : result.emailsToSendInvite.length, - membersToRemove: verbose - ? result.membersToRemove - : result.membersToRemove.length, - emailsToRevokeInvite: verbose - ? result.emailsToRevokeInvite - : result.emailsToRevokeInvite.length, - }, - null, - 2 - ) - ) -} - -async function main() { - const subscription = await Subscription.findOne({ - _id: subscriptionId, - }).exec() - if (!subscription) { - console.error(`subscription with id=${subscriptionId} not found`) - process.exit(1) - } - if (subscription.managedUsersEnabled && removeMembersNotIncluded) { - console.warn( - `subscription with id=${subscriptionId} has 'managedUsersEnabled=true'` + - `'--removeMembersNotIncluded' has been disabled` - ) - removeMembersNotIncluded = false - } - await Stream.pipeline( - fs.createReadStream(filename), - parse({ - columns: true, - }), - processRows - ) -} - -main() - .then(() => process.exit(0)) - .catch(error => { - console.error(error) - process.exit(1) - }) diff --git a/services/web/scripts/add_user_count_to_csv.mjs b/services/web/scripts/add_user_count_to_csv.mjs deleted file mode 100644 index 294b4d93a8..0000000000 --- a/services/web/scripts/add_user_count_to_csv.mjs +++ /dev/null @@ -1,96 +0,0 @@ -// Usage: node scripts/add_user_count_to_csv.mjs [OPTS] [INPUT-FILE] -// Looks up the number of users for each domain in the input csv file and adds -// columns for the number of users in the domain, subdomains, and total. -import fs from 'node:fs' -// https://github.com/import-js/eslint-plugin-import/issues/1810 -// eslint-disable-next-line import/no-unresolved -import * as csv from 'csv/sync' -import minimist from 'minimist' -import UserGetter from '../app/src/Features/User/UserGetter.mjs' -import { db } from '../app/src/infrastructure/mongodb.mjs' -import _ from 'lodash' -import { scriptRunner } from './lib/ScriptRunner.mjs' - -const argv = minimist(process.argv.slice(2), { - string: ['domain', 'output'], - boolean: ['help'], - alias: { - domain: 'd', - output: 'o', - }, - default: { - domain: 'Email domain', - output: '/dev/stdout', - }, -}) - -if (argv.help || argv._.length > 1) { - console.error(`Usage: node scripts/add_user_count_to_csv.mjs [OPTS] [INPUT-FILE] - Looks up the number of users for each domain in the input file and adds - columns for the number of users in the domain, subdomains, and total. - - Options: - - --domain name of the csv column containing the email domain (default: "Email domain") - --output output file (default: /dev/stdout) - `) - process.exit(1) -} - -const input = fs.readFileSync(argv._[0], 'utf8') -const records = csv.parse(input, { columns: true }) - -if (records.length === 0) { - console.error('No records in input file') - process.exit(1) -} - -async function main() { - for (const record of records) { - const domain = record[argv.domain] - const { domainUserCount, subdomainUserCount } = await getUserCount(domain, { - _id: 1, - }) - record['Domain Users'] = domainUserCount - record['Subdomain Users'] = subdomainUserCount - record['Total Users'] = domainUserCount + subdomainUserCount - } - const output = csv.stringify(records, { header: true }) - fs.writeFileSync(argv.output, output) -} - -async function getUserCount(domain) { - const domainUsers = await UserGetter.promises.getUsersByHostname(domain, { - _id: 1, - }) - const subdomainUsers = await getUsersByHostnameWithSubdomain(domain, { - _id: 1, - }) - return { - domainUserCount: domainUsers.length, - subdomainUserCount: subdomainUsers.length, - } -} - -async function getUsersByHostnameWithSubdomain(domain, projection) { - const reversedDomain = domain.trim().split('').reverse().join('') - const reversedDomainRegex = _.escapeRegExp(reversedDomain) - const query = { - emails: { $exists: true }, - // look for users in subdomains of a domain, but not the domain itself - // e.g. for domain 'foo.edu', match 'cs.foo.edu' but not 'foo.edu' - // we use the reversed hostname index to do this efficiently - // we need to escape the domain name to prevent '.' from matching any character - 'emails.reversedHostname': { $regex: '^' + reversedDomainRegex + '\\.' }, - } - return await db.users.find(query, { projection }).toArray() -} - -try { - await scriptRunner(main) - console.log('Done') - process.exit(0) -} catch (error) { - console.error(error) - process.exit(1) -} diff --git a/services/web/scripts/analytics/backfill_recurly_to_subscription_mapping.mjs b/services/web/scripts/analytics/backfill_recurly_to_subscription_mapping.mjs deleted file mode 100644 index 92b0835944..0000000000 --- a/services/web/scripts/analytics/backfill_recurly_to_subscription_mapping.mjs +++ /dev/null @@ -1,121 +0,0 @@ -/** - * This script backfills the account mapping for subscriptions that are active and have a group plan. - * - * The mapping joins a recurlySubscription_id to a subscription _id in BigQuery. - * - * This script has an assumption that it is being run in a clean slate condition, it will create some - * duplicate mappings if run multiple times. The Analytics team will have the expectation - * that this table may need to be deduplicated as it is an event sourcing record. - * - * Call it with `--commit` to actually register the mappings. - * Call it with `--verbose` to see debug logs. - * Call it with `--endDate=` to stop processing at a certain date - */ -import logger from '@overleaf/logger' -import minimist from 'minimist' -import { z } from 'zod' -import { batchedUpdate } from '@overleaf/mongo-utils/batchedUpdate.js' -import { db } from '../../app/src/infrastructure/mongodb.mjs' -import AccountMappingHelper from '../../app/src/Features/Analytics/AccountMappingHelper.mjs' -import AnalyticsManager from '../../app/src/Features/Analytics/AnalyticsManager.mjs' -import { triggerGracefulShutdown } from '../../app/src/infrastructure/GracefulShutdown.mjs' -import { scriptRunner } from '../lib/ScriptRunner.mjs' - -const { registerAccountMapping } = AnalyticsManager - -const paramsSchema = z.object({ - endDate: z.string().datetime(), - commit: z.boolean().default(false), - verbose: z.boolean().default(false), -}) - -let mapped = 0 -let subscriptionCount = 0 - -const now = new Date().toISOString() // use the same timestamp for all mappings - -const seenSubscriptions = new Set() - -function registerMapping(subscription) { - if (seenSubscriptions.has(subscription._id)) { - logger.warn({ subscription }, 'duplicate subscription found, skipping') - return - } - seenSubscriptions.add(subscription._id) - subscriptionCount++ - - const mapping = AccountMappingHelper.generateSubscriptionToRecurlyMapping( - subscription._id, - subscription.recurlySubscription_id, - now - ) - logger.debug( - { - recurly: subscription.recurlySubscription_id, - mapping, - }, - `processing subscription ${subscription._id}` - ) - if (opts.commit) { - registerAccountMapping(mapping) - mapped++ - } -} - -async function main(trackProgress) { - const additionalBatchedUpdateOptions = {} - - if (opts.endDate) { - additionalBatchedUpdateOptions.BATCH_RANGE_END = opts.endDate - } - - await batchedUpdate( - db.subscriptions, - { - 'recurlyStatus.state': 'active', - groupPlan: true, - }, - subscriptions => subscriptions.forEach(registerMapping), - { - _id: 1, - recurlySubscription_id: 1, - }, - { - readPreference: 'secondaryPreferred', - }, - { - verboseLogging: opts.verbose, - ...additionalBatchedUpdateOptions, - trackProgress, - } - ) - - logger.debug({}, `${subscriptionCount} subscriptions processed`) - if (opts.commit) { - logger.debug({}, `${mapped} mappings registered`) - } -} - -const { error, data: opts } = paramsSchema.safeParse( - minimist(process.argv.slice(2), { - boolean: ['commit', 'verbose'], - string: ['endDate'], - }) -) - -logger.logger.level(opts.verbose ? 'debug' : 'info') - -if (error) { - logger.error({ error }, 'error with parameters') - triggerGracefulShutdown(done => done(1)) -} else { - logger.info(opts, opts.commit ? 'COMMITTING' : 'DRY RUN') - await scriptRunner(main) - - triggerGracefulShutdown({ - close(done) { - logger.info({}, 'shutting down') - done() - }, - }) -} diff --git a/services/web/scripts/analytics/backfill_stripe_to_subscription_mapping.mjs b/services/web/scripts/analytics/backfill_stripe_to_subscription_mapping.mjs deleted file mode 100644 index 380e3d5fc6..0000000000 --- a/services/web/scripts/analytics/backfill_stripe_to_subscription_mapping.mjs +++ /dev/null @@ -1,131 +0,0 @@ -/** - * This script backfills the account mapping for subscriptions that are active and backed by Stripe. - * - * The mapping joins a Stripe subscription ID to a subscription _id in BigQuery. - * - * This script has an assumption that it is being run in a clean slate condition, it will create some - * duplicate mappings if run multiple times. The Analytics team will have the expectation - * that this table may need to be deduplicated as it is an event sourcing record. - * - * Call it with `--commit` to actually register the mappings. - * Call it with `--verbose` to see debug logs. - * Call it with `--endDate=` to stop processing at a certain date, for example `--endDate=2024-01-01` - */ -import logger from '@overleaf/logger' -import minimist from 'minimist' -import { batchedUpdate } from '@overleaf/mongo-utils/batchedUpdate.js' -import { db } from '../../app/src/infrastructure/mongodb.mjs' -import AccountMappingHelper from '../../app/src/Features/Analytics/AccountMappingHelper.mjs' -import AnalyticsManager from '../../app/src/Features/Analytics/AnalyticsManager.mjs' -import { gracefulShutdown } from '../../app/src/infrastructure/GracefulShutdown.mjs' -import Validation from '../../app/src/infrastructure/Validation.mjs' -import { scriptRunner } from '../lib/ScriptRunner.mjs' - -const { registerAccountMapping } = AnalyticsManager - -const paramsSchema = Validation.z.object({ - endDate: Validation.z.iso - .date() - .transform(v => new Date(v).toISOString()) - .optional(), - commit: Validation.z.boolean().default(false).optional(), - verbose: Validation.z.boolean().default(false).optional(), -}) - -let mapped = 0 -let subscriptionCount = 0 - -const now = new Date().toISOString() // use the same timestamp for all mappings - -const seenSubscriptions = new Set() - -function registerMapping(subscription) { - if (seenSubscriptions.has(subscription._id)) { - logger.warn({ subscription }, 'duplicate subscription found, skipping') - return - } - seenSubscriptions.add(subscription._id) - subscriptionCount++ - - const mapping = AccountMappingHelper.generateSubscriptionToStripeMapping( - subscription._id, - subscription.paymentProvider.subscriptionId, - subscription.paymentProvider.service, - now - ) - logger.debug( - { - stripe: subscription.paymentProvider.subscriptionId, - stripeService: subscription.paymentProvider.service, - mapping, - }, - `processing subscription ${subscription._id}` - ) - if (commit) { - registerAccountMapping(mapping) - mapped++ - } -} - -async function main(trackProgress) { - const additionalBatchedUpdateOptions = {} - - if (endDate) { - additionalBatchedUpdateOptions.BATCH_RANGE_END = endDate - } - - await batchedUpdate( - db.subscriptions, - { - 'paymentProvider.service': { $in: ['stripe-us', 'stripe-uk'] }, - }, - subscriptions => subscriptions.forEach(registerMapping), - { - _id: 1, - 'paymentProvider.subscriptionId': 1, - 'paymentProvider.service': 1, - }, - { - readPreference: 'secondaryPreferred', - }, - { - verboseLogging: verbose, - ...additionalBatchedUpdateOptions, - trackProgress, - } - ) - - logger.debug({}, `${subscriptionCount} subscriptions processed`) - if (commit) { - logger.debug({}, `${mapped} mappings registered`) - } -} - -const { error, data } = paramsSchema.safeParse( - minimist(process.argv.slice(2), { - boolean: ['commit', 'verbose'], - string: ['endDate'], - }) -) - -if (error) { - logger.error({ error }, 'error with parameters') - await gracefulShutdown({ - close(done) { - logger.info({}, 'shutting down') - done() - }, - }) - process.exit(1) -} -const { commit, endDate, verbose } = data -logger.logger.level(verbose ? 'debug' : 'info') -logger.info({ verbose, commit, endDate }, commit ? 'COMMITTING' : 'DRY RUN') -await scriptRunner(main) -await gracefulShutdown({ - close(done) { - logger.info({}, 'shutting down') - done() - }, -}) -process.exit() diff --git a/services/web/scripts/analytics/helpers/GoogleBigQueryHelper.mjs b/services/web/scripts/analytics/helpers/GoogleBigQueryHelper.mjs deleted file mode 100644 index 7bdc354bf6..0000000000 --- a/services/web/scripts/analytics/helpers/GoogleBigQueryHelper.mjs +++ /dev/null @@ -1,30 +0,0 @@ -import { BigQuery as GoogleBigQuery } from '@google-cloud/bigquery' - -let dataset = null - -function getDataset() { - if (!dataset) { - console.log( - 'Connecting to BigQuery dataset: ', - process.env.BQ_PROJECT_ID, - process.env.BQ_DATASET_V2 - ) - - dataset = new GoogleBigQuery({ - projectId: process.env.BQ_PROJECT_ID, - keyFilename: process.env.GCS_KEY_FILE, - }).dataset(process.env.BQ_DATASET_V2) - } - - return dataset -} - -async function query(query, params = {}) { - const [job] = await getDataset().createQueryJob({ query, params }) - const [rows] = await job.getQueryResults() - return rows -} - -export default { - query, -} diff --git a/services/web/scripts/analytics/sync_group_subscription_memberships.mjs b/services/web/scripts/analytics/sync_group_subscription_memberships.mjs deleted file mode 100644 index 9480f7c32e..0000000000 --- a/services/web/scripts/analytics/sync_group_subscription_memberships.mjs +++ /dev/null @@ -1,282 +0,0 @@ -import GoogleBigQueryHelper from './helpers/GoogleBigQueryHelper.mjs' -import { Subscription } from '../../app/src/models/Subscription.mjs' -import AnalyticsManager from '../../app/src/Features/Analytics/AnalyticsManager.mjs' -import { DeletedSubscription } from '../../app/src/models/DeletedSubscription.mjs' -import minimist from 'minimist' -import _ from 'lodash' -import mongodb from 'mongodb-legacy' -import { scriptRunner } from '../lib/ScriptRunner.mjs' - -const { ObjectId } = mongodb - -let BATCH_SIZE, COMMIT, VERBOSE - -async function main() { - console.log('## Syncing group subscription memberships...') - - const subscriptionsCount = await Subscription.countDocuments({ - groupPlan: true, - }) - const deletedSubscriptionsCount = await DeletedSubscription.countDocuments({ - 'subscription.groupPlan': true, - }) - - console.log( - `## Going to synchronize ${subscriptionsCount} subscriptions and ${deletedSubscriptionsCount} deleted subscriptions` - ) - - await checkActiveSubscriptions() - await checkDeletedSubscriptions() -} - -async function checkActiveSubscriptions() { - let subscriptions - const processedSubscriptionIds = new Set() - - const cursor = Subscription.find( - { groupPlan: true }, - { recurlySubscription_id: 1, member_ids: 1 } - ) - .sort('_id') - .cursor() - - do { - subscriptions = [] - while (subscriptions.length <= BATCH_SIZE) { - const next = await cursor.next() - if (!next) { - break - } - subscriptions.push(next) - } - - if (subscriptions.length) { - const groupIds = subscriptions.map(sub => sub._id) - const bigQueryGroupMemberships = - await fetchBigQueryMembershipStatuses(groupIds) - const membershipsByGroupId = _.groupBy( - bigQueryGroupMemberships, - 'group_id' - ) - - for (const subscription of subscriptions) { - const subscriptionId = subscription._id.toString() - if (!processedSubscriptionIds.has(subscriptionId)) { - await checkSubscriptionMemberships( - subscription, - membershipsByGroupId[subscriptionId] || [] - ) - processedSubscriptionIds.add(subscriptionId) - } - } - } - } while (subscriptions.length > 0) -} - -async function checkDeletedSubscriptions() { - let deletedSubscriptions - const processedSubscriptionIds = new Set() - - const cursor = DeletedSubscription.find( - { 'subscription.groupPlan': true }, - { subscription: 1 } - ).cursor() - - do { - deletedSubscriptions = [] - while (deletedSubscriptions.length <= BATCH_SIZE) { - const next = await cursor.next() - if (!next) { - break - } - deletedSubscriptions.push(next.toObject().subscription) - } - - if (deletedSubscriptions.length) { - const groupIds = deletedSubscriptions.map(sub => sub._id.toString()) - const bigQueryGroupMemberships = - await fetchBigQueryMembershipStatuses(groupIds) - - const membershipsByGroupId = _.groupBy( - bigQueryGroupMemberships, - 'group_id' - ) - - for (const deletedSubscription of deletedSubscriptions) { - const subscriptionId = deletedSubscription._id.toString() - if (!processedSubscriptionIds.has(subscriptionId)) { - await checkDeletedSubscriptionMemberships( - deletedSubscription, - membershipsByGroupId[subscriptionId] || [] - ) - processedSubscriptionIds.add(subscriptionId) - } - } - } - } while (deletedSubscriptions.length > 0) -} - -async function checkSubscriptionMemberships(subscription, membershipStatuses) { - if (VERBOSE) { - console.log( - '\n###########################################################################################', - '\n# Subscription (mongo): ', - '\n# _id: \t\t\t\t', - subscription._id.toString(), - '\n# member_ids: \t\t\t', - subscription.member_ids.map(_id => _id.toString()), - '\n# recurlySubscription_id: \t', - subscription.recurlySubscription_id - ) - console.log('#\n# Membership statuses found in BigQuery: ') - console.table(membershipStatuses) - } - // create missing `joined` events when membership status is missing - for (const memberId of subscription.member_ids) { - if ( - !_.find(membershipStatuses, { - user_id: memberId.toString(), - is_member: true, - }) - ) { - await sendCorrectiveEvent( - memberId, - 'group-subscription-joined', - subscription - ) - } - } - // create missing `left` events if user is not a member of the group anymore - for (const { user_id: userId, is_member: isMember } of membershipStatuses) { - if ( - isMember && - !subscription.member_ids.some(id => id.toString() === userId) - ) { - await sendCorrectiveEvent(userId, 'group-subscription-left', subscription) - } - } -} - -async function checkDeletedSubscriptionMemberships( - subscription, - membershipStatuses -) { - if (VERBOSE) { - console.log( - '\n###########################################################################################', - '\n# Deleted subscription (mongo): ', - '\n# _id: \t\t\t\t', - subscription._id.toString(), - '\n# member_ids: \t\t\t', - subscription.member_ids.map(_id => _id.toString()), - '\n# recurlySubscription_id: \t', - subscription.recurlySubscription_id - ) - console.log('#\n# Membership statuses found in BigQuery: ') - console.table(membershipStatuses) - } - - const updatedUserIds = new Set() - // create missing `left` events if user was a member of the group in BQ and status is not up-to-date - for (const memberId of subscription.member_ids.map(id => id.toString())) { - if ( - _.find(membershipStatuses, { - user_id: memberId, - is_member: true, - }) - ) { - await sendCorrectiveEvent( - memberId, - 'group-subscription-left', - subscription - ) - updatedUserIds.add(memberId) - } - } - // for cases where the user has been removed from the subscription before it was deleted and status is not up-to-date - for (const { user_id: userId, is_member: isMember } of membershipStatuses) { - if (isMember && !updatedUserIds.has(userId)) { - await sendCorrectiveEvent(userId, 'group-subscription-left', subscription) - updatedUserIds.add(userId) - } - } -} - -async function sendCorrectiveEvent(userId, event, subscription) { - if (!ObjectId.isValid(userId)) { - console.warn(`Skipping '${event}' for user ${userId}: invalid user ID`) - return - } - const segmentation = { - groupId: subscription._id.toString(), - subscriptionId: subscription.recurlySubscription_id, - source: 'sync', - } - if (COMMIT) { - console.log( - `Sending event '${event}' for user ${userId} with segmentation: ${JSON.stringify( - segmentation - )}` - ) - await AnalyticsManager.recordEventForUser(userId, event, segmentation) - } else { - console.log( - `Dry run - would send event '${event}' for user ${userId} with segmentation: ${JSON.stringify( - segmentation - )}` - ) - } -} - -/** - * @param {Array} groupIds - * @return {Promise<*>} - */ -async function fetchBigQueryMembershipStatuses(groupIds) { - const query = `\ - WITH user_memberships AS ( - SELECT - group_id, - COALESCE(user_aliases.user_id, ugm.user_id) AS user_id, - is_member, - ugm.created_at - FROM INT_user_group_memberships ugm - LEFT JOIN INT_user_aliases user_aliases ON ugm.user_id = user_aliases.analytics_id - WHERE ugm.group_id IN UNNEST(@groupIds) - ), - ordered_status AS ( - SELECT *, - ROW_NUMBER() OVER(PARTITION BY group_id, user_id ORDER BY created_at DESC) AS row_number - FROM user_memberships - ) - SELECT group_id, user_id, is_member, created_at FROM ordered_status - WHERE row_number = 1; - ` - - return await GoogleBigQueryHelper.query(query, { - groupIds: groupIds.map(id => id.toString()), - }) -} - -const setup = () => { - const argv = minimist(process.argv.slice(2)) - BATCH_SIZE = argv.batchSize ? parseInt(argv.batchSize, 10) : 100 - COMMIT = argv.commit !== undefined - VERBOSE = argv.debug !== undefined - if (!COMMIT) { - console.warn('Doing dry run without --commit') - } - if (VERBOSE) { - console.log('Running in verbose mode') - } -} - -setup() -try { - await scriptRunner(main) - console.error('Done.') - process.exit(0) -} catch (error) { - console.error({ error }) - process.exit(1) -} diff --git a/services/web/scripts/back_fill_warning_user_personal_and_group_subscription.mjs b/services/web/scripts/back_fill_warning_user_personal_and_group_subscription.mjs deleted file mode 100644 index 08e8b0c84b..0000000000 --- a/services/web/scripts/back_fill_warning_user_personal_and_group_subscription.mjs +++ /dev/null @@ -1,80 +0,0 @@ -import NotificationsBuilder from '../app/src/Features/Notifications/NotificationsBuilder.mjs' -import { db } from '../app/src/infrastructure/mongodb.mjs' -import { batchedUpdate } from '@overleaf/mongo-utils/batchedUpdate.js' -import { scriptRunner } from './lib/ScriptRunner.mjs' - -const DRY_RUN = !process.argv.includes('--dry-run=false') - -if (DRY_RUN) { - console.log('Doing dry run') -} - -async function processBatch(groupSubscriptionsBatch) { - console.log('\n') - console.log('----- Batch computation started -----') - const flattenedMemberIds = groupSubscriptionsBatch - .map(sub => sub.member_ids) - .flatMap(memberId => memberId) - const uniqueFlattenedMemberIds = [...new Set(flattenedMemberIds)] - - const userWithIndividualAndGroupSubscriptions = await db.subscriptions - .find({ - groupPlan: false, - 'recurlyStatus.state': 'active', - admin_id: { $in: uniqueFlattenedMemberIds }, - }) - .toArray() - - console.log( - `Found ${userWithIndividualAndGroupSubscriptions.length} affected users in this batch` - ) - - if (DRY_RUN) { - console.error('---') - console.error('Dry-run enabled, use --dry-run=false to commit changes') - console.error('---') - } else { - if (userWithIndividualAndGroupSubscriptions.length > 0) { - console.log( - `Notifying ${userWithIndividualAndGroupSubscriptions.length} users` - ) - - for (const notif of userWithIndividualAndGroupSubscriptions) { - await NotificationsBuilder.promises - .personalAndGroupSubscriptions(notif.admin_id.toString()) - .create() - } - - console.log( - `${userWithIndividualAndGroupSubscriptions.length} users successfully notified in this batch` - ) - } else { - console.log( - 'No users currently subscribe to both individual and group subscription in this batch' - ) - } - } -} - -async function main(trackProgress) { - await batchedUpdate( - db.subscriptions, - { groupPlan: true }, - processBatch, - { - member_ids: 1, - }, - undefined, - { - trackProgress, - } - ) -} - -try { - await scriptRunner(main) - process.exit(0) -} catch (error) { - console.error(error) - process.exit(1) -} diff --git a/services/web/scripts/backfill_library_references_search.mjs b/services/web/scripts/backfill_library_references_search.mjs deleted file mode 100644 index 92472dec32..0000000000 --- a/services/web/scripts/backfill_library_references_search.mjs +++ /dev/null @@ -1,113 +0,0 @@ -// @ts-check -import minimist from 'minimist' -import logger from '@overleaf/logger' -import { db } from '../app/src/infrastructure/mongodb.mjs' -import { - buildSearchTokens, - buildMatchTokens, - docSchema, -} from '../modules/library/app/src/LibraryReferenceRepository.mts' -import { tokenize } from '../modules/library/app/src/bibtex-search-tokens.mts' -import { scriptRunner } from './lib/ScriptRunner.mjs' - -/** @typedef {import('mongodb').AnyBulkWriteOperation} AnyBulkWriteOperation */ - -const argv = minimist(process.argv.slice(2), { - boolean: ['commit', 'all', 'help'], - default: { 'batch-size': 1000 }, -}) - -function usage() { - logger.info( - {}, - `Usage: node backfill_library_references_search.mjs [options] - -Populates searchKey, searchTokens and matchTokens on libraryReferences so -the account-level library search and import duplicate-detection can index -them. Also unsets the obsolete fields.$[].searchValue. Safe to rerun; picks -up only un-indexed rows by default. - -Options: - --commit Apply changes. Without this, runs as a dry run. - --all Re-index every row, not just rows where searchTokens - is null. Use when the tokenization format has changed. - --batch-size bulkWrite batch size (default 1000). -` - ) -} - -if (argv.help) { - usage() - process.exit(0) -} - -const BATCH_SIZE = Number(argv['batch-size']) - -/** @param {(message: string) => Promise} trackProgress */ -async function backfill(trackProgress) { - const filter = argv.all ? {} : { searchTokens: null } - const cursor = db.libraryReferences - .find(filter) - .project({ key: 1, type: 1, fields: 1, updatedAt: 1 }) - - let processed = 0 - /** @type {AnyBulkWriteOperation[]} */ - let ops = [] - - const flush = async () => { - if (ops.length === 0) return - if (argv.commit) { - await db.libraryReferences.bulkWrite(ops, { ordered: false }) - } - processed += ops.length - await trackProgress( - `${argv.commit ? 'wrote' : '[dry-run]'} ${processed} docs` - ) - ops = [] - } - - for await (const doc of cursor) { - const entry = docSchema.parse({ - ...doc, - type: doc.type ?? 'misc', - updatedAt: doc.updatedAt ?? new Date(0), - }) - const searchKey = tokenize(doc.key) - const searchTokens = buildSearchTokens(entry) - const matchTokens = buildMatchTokens(entry) - ops.push({ - updateOne: { - filter: { _id: doc._id }, - update: { - $set: { searchKey, searchTokens, matchTokens }, - $unset: { 'fields.$[].searchValue': 1 }, - }, - }, - }) - if (ops.length >= BATCH_SIZE) { - await flush() - } - } - await flush() - await trackProgress(`done; processed ${processed} docs`) -} - -/** @param {(message: string) => Promise} trackProgress */ -async function main(trackProgress) { - if (!argv.commit) { - await trackProgress('DRY RUN. Pass --commit to apply changes.') - } - await backfill(trackProgress) -} - -try { - await scriptRunner(main, { - commit: Boolean(argv.commit), - all: Boolean(argv.all), - batchSize: BATCH_SIZE, - }) - process.exit(0) -} catch (err) { - logger.error({ err }, 'backfill failed') - process.exit(1) -} diff --git a/services/web/scripts/backfill_mixpanel_user_properties.mjs b/services/web/scripts/backfill_mixpanel_user_properties.mjs deleted file mode 100644 index 053651fa1c..0000000000 --- a/services/web/scripts/backfill_mixpanel_user_properties.mjs +++ /dev/null @@ -1,136 +0,0 @@ -// @ts-check -import '../app/src/models/User.mjs' -import { batchedUpdateWithResultHandling } from '@overleaf/mongo-utils/batchedUpdate.js' -import { promiseMapWithLimit } from '@overleaf/promise-utils' -import Queues from '../app/src/infrastructure/Queues.mjs' -import SubscriptionLocator from '../app/src/Features/Subscription/SubscriptionLocator.mjs' -import PlansLocator from '../app/src/Features/Subscription/PlansLocator.mjs' -import FeaturesHelper from '../app/src/Features/Subscription/FeaturesHelper.mjs' -import { db } from '../app/src/infrastructure/mongodb.mjs' - -const { getQueue } = Queues -const WRITE_CONCURRENCY = parseInt(process.env.WRITE_CONCURRENCY || '10', 10) - -const mixpanelSinkQueue = getQueue('analytics-mixpanel-sink') - -/** - * @param {any} user - */ -async function processUser(user) { - const analyticsId = user.analyticsId || user._id - - await _sendPropertyToQueue(analyticsId, 'user-id', user._id) - await _sendPropertyToQueue(analyticsId, 'analytics-id', analyticsId) - await _sendPropertyToQueue(analyticsId, 'created-at', user.signUpDate) - if (user.alphaProgram !== undefined) { - await _sendPropertyToQueue(analyticsId, 'alpha-program', user.alphaProgram) - } - if (user.betaProgram !== undefined) { - await _sendPropertyToQueue(analyticsId, 'beta-program', user.betaProgram) - } - - const groupSubscriptionPlanCode = await _getGroupSubscriptionPlanCode( - user._id - ) - if (groupSubscriptionPlanCode) { - await _sendPropertyToQueue( - analyticsId, - 'group-subscription-plan-code', - groupSubscriptionPlanCode - ) - } - - const matchedFeatureSet = FeaturesHelper.getMatchedFeatureSet(user.features) - if (matchedFeatureSet !== 'personal') { - await _sendPropertyToQueue(analyticsId, 'feature-set', matchedFeatureSet) - } - - if (user.splitTests) { - for (const splitTestName of Object.keys(user.splitTests)) { - const assignments = user.splitTests[splitTestName] - if (Array.isArray(assignments)) { - for (const assignment of assignments) { - await _sendPropertyToQueue( - analyticsId, - `split-test-${splitTestName}-${assignment.versionNumber}`, - `${assignment.variantName}` - ) - } - } - } - } -} - -/** - * @param {any} userId - */ -async function _getGroupSubscriptionPlanCode(userId) { - const subscriptions = - await SubscriptionLocator.promises.getMemberSubscriptions(userId) - let bestPlanCode = null - let bestFeatures = {} - for (const subscription of subscriptions) { - const plan = PlansLocator.findLocalPlanInSettings(subscription.planCode) - if ( - plan && - plan.features && - FeaturesHelper.isFeatureSetBetter(plan.features, bestFeatures) - ) { - bestPlanCode = plan.planCode - bestFeatures = plan.features - } - } - return bestPlanCode -} - -/** - * @param {any} analyticsId - * @param {any} propertyName - * @param {any} propertyValue - * @param {any} [createdAt] - */ -async function _sendPropertyToQueue( - analyticsId, - propertyName, - propertyValue, - createdAt = new Date() -) { - if (propertyValue == null) { - return - } - await mixpanelSinkQueue.add('user-property', { - analyticsId, - propertyName, - propertyValue, - createdAt, - }) -} - -/** - * @param {any} _ - * @param {any} users - */ -async function processBatch(_, users) { - await promiseMapWithLimit(WRITE_CONCURRENCY, users, async user => { - await processUser(user) - }) -} - -batchedUpdateWithResultHandling( - db.users, - { - $nor: [ - { thirdPartyIdentifiers: { $exists: false } }, - { thirdPartyIdentifiers: { $size: 0 } }, - ], - }, - processBatch, - { - _id: true, - analyticsId: true, - signUpDate: true, - splitTests: true, - alphaProgram: true, - betaProgram: true, - } -) diff --git a/services/web/scripts/backfill_user_properties.mjs b/services/web/scripts/backfill_user_properties.mjs deleted file mode 100644 index 70daa94486..0000000000 --- a/services/web/scripts/backfill_user_properties.mjs +++ /dev/null @@ -1,61 +0,0 @@ -import { batchedUpdateWithResultHandling } from '@overleaf/mongo-utils/batchedUpdate.js' -import { promiseMapWithLimit } from '@overleaf/promise-utils' -import SubscriptionLocator from '../app/src/Features/Subscription/SubscriptionLocator.mjs' -import PlansLocator from '../app/src/Features/Subscription/PlansLocator.mjs' -import FeaturesHelper from '../app/src/Features/Subscription/FeaturesHelper.mjs' -import AnalyticsManager from '../app/src/Features/Analytics/AnalyticsManager.mjs' -import { db } from '../app/src/infrastructure/mongodb.mjs' - -const WRITE_CONCURRENCY = parseInt(process.env.WRITE_CONCURRENCY, 10) || 10 - -async function getGroupSubscriptionPlanCode(userId) { - const subscriptions = - await SubscriptionLocator.promises.getMemberSubscriptions(userId) - let bestPlanCode = null - let bestFeatures = {} - for (const subscription of subscriptions) { - const plan = PlansLocator.findLocalPlanInSettings(subscription.planCode) - if ( - plan && - FeaturesHelper.isFeatureSetBetter(plan.features, bestFeatures) - ) { - bestPlanCode = plan.planCode - bestFeatures = plan.features - } - } - return bestPlanCode -} - -async function processUser(user) { - const analyticsId = user.analyticsId || user._id - - const groupSubscriptionPlanCode = await getGroupSubscriptionPlanCode(user._id) - if (groupSubscriptionPlanCode) { - await AnalyticsManager.setUserPropertyForAnalyticsId( - analyticsId, - 'group-subscription-plan-code', - groupSubscriptionPlanCode - ) - } - - const matchedFeatureSet = FeaturesHelper.getMatchedFeatureSet(user.features) - if (matchedFeatureSet !== 'personal') { - await AnalyticsManager.setUserPropertyForAnalyticsId( - analyticsId, - 'feature-set', - matchedFeatureSet - ) - } -} - -async function processBatch(users) { - await promiseMapWithLimit(WRITE_CONCURRENCY, users, async user => { - await processUser(user) - }) -} - -batchedUpdateWithResultHandling(db.users, {}, processBatch, { - _id: true, - analyticsId: true, - features: true, -}) diff --git a/services/web/scripts/backfill_users_sso_attribute.mjs b/services/web/scripts/backfill_users_sso_attribute.mjs deleted file mode 100644 index d166e06758..0000000000 --- a/services/web/scripts/backfill_users_sso_attribute.mjs +++ /dev/null @@ -1,33 +0,0 @@ -import SAMLUserIdAttributeBatchHandler from '../modules/saas-authentication/app/src/SAML/SAMLUserIdAttributeBatchHandler.mjs' - -const startInstitutionId = parseInt(process.argv[2]) -const endInstitutionId = parseInt(process.argv[3]) - -process.env.LOG_LEVEL = 'info' - -process.env.MONGO_CONNECTION_STRING = - process.env.READ_ONLY_MONGO_CONNECTION_STRING - -console.log('Checking users at institutions') - -console.log( - 'Start institution ID:', - startInstitutionId || - 'none provided, will start at beginning of ordered list.' -) -console.log( - 'End institution ID:', - endInstitutionId || 'none provided, will go to end of ordered list.' -) - -try { - const result = await SAMLUserIdAttributeBatchHandler.check( - startInstitutionId, - endInstitutionId - ) - console.log(result) - process.exit(0) -} catch (error) { - console.error(error) - process.exit(1) -} diff --git a/services/web/scripts/batch_identify_to_cio.mjs b/services/web/scripts/batch_identify_to_cio.mjs deleted file mode 100644 index 0de7971d8f..0000000000 --- a/services/web/scripts/batch_identify_to_cio.mjs +++ /dev/null @@ -1,465 +0,0 @@ -#!/usr/bin/env node - -/** - * This script reads a CSV file (output from migrate_mailchimp_to_cio.mjs or - * export_active_subscription_users_csv.mjs) and - * makes batch identify requests to Customer.io using the CDP Analytics node library. - * - * Usage: - * node scripts/batch_identify_to_cio.mjs --input INPUT-FILE [OPTIONS] - * - * Example: - * node scripts/batch_identify_to_cio.mjs --input /tmp/customerio_import.csv - * CUSTOMER_IO_API_KEY=xxx node scripts/batch_identify_to_cio.mjs --input /tmp/customerio_import.csv --commit - * - * Resuming after failure: - * CUSTOMER_IO_API_KEY=xxx node scripts/batch_identify_to_cio.mjs --input /tmp/customerio_import.csv --commit --skip 50000 - * - * Options: - * --input, -i PATH Input CSV file (from migrate_mailchimp_to_cio.mjs) (required) - * --commit Actually send to Customer.io (default is dry-run) - * --skip N Skip the first N rows (for resuming after failure) - * --batch-size N Maximum items per batch (default: 1000) - * --help, -h Show this help message - * - * Environment Variables: - * CUSTOMER_IO_API_KEY Customer.io CDP API key (required when using --commit) - * - * CSV Input Format (from migrate_mailchimp_to_cio.mjs): - * - email: Subscriber email address - * - overleafId: Overleaf user ID (from mongo_id) - * - created_at: Unix timestamp - * - cio_subscription_preferences.topics.: 'true' if subscribed - * - labsExperiments: JSON array of experiment names - * - * CSV Input Format (from export_active_subscription_users_csv.mjs): - * - user_id: Overleaf user ID - * - email: Subscriber email address - * - plan_type, display_plan_type, pre_migration_plan_type, - * pre_migration_display_plan_type, plan_term, ai_plan, ai_plan_term, - * next_renewal_date, expiry_date, group_ai_enabled, group_role - * - * Rate Limiting: - * - Max 3000 requests per 3 seconds (Customer.io limit) - * - Progress is logged every 10000 rows for recovery purposes - * - * Notes: - * - Users are identified by email address (used as userId) - * - The library handles batching internally via maxEventsInBatch - */ - -import fs, { createReadStream } from 'node:fs' -import * as csv from 'csv' -import minimist from 'minimist' -import { Analytics } from '@customerio/cdp-analytics-node' -import { scriptRunner } from './lib/ScriptRunner.mjs' - -const DEFAULT_BATCH_SIZE = 1000 -const RATE_LIMIT_REQUESTS = 3000 -const RATE_LIMIT_WINDOW_MS = 3000 -const PROGRESS_LOG_INTERVAL = 10000 - -function usage() { - console.error(`Usage: node scripts/batch_identify_to_cio.mjs --input INPUT-FILE [OPTIONS] - -Options: - --input, -i PATH Input CSV file (from migrate_mailchimp_to_cio.mjs) (required) - --commit Actually send to Customer.io (default is dry-run) - --skip N Skip the first N rows (for resuming after failure) - --batch-size N Maximum items per batch (default: ${DEFAULT_BATCH_SIZE}) - --help, -h Show this help message - -Environment Variables: - CUSTOMER_IO_API_KEY Customer.io CDP API key (required when using --commit) -`) - process.exit(1) -} - -/** - * Simple rate limiter that enforces max requests per time window - */ -class RateLimiter { - constructor(maxRequests, windowMs) { - this.maxRequests = maxRequests - this.windowMs = windowMs - this.requests = [] - } - - async waitIfNeeded() { - const now = Date.now() - // Remove requests outside the current window - this.requests = this.requests.filter(t => now - t < this.windowMs) - - if (this.requests.length >= this.maxRequests) { - // Wait until the oldest request falls outside the window - const oldestRequest = this.requests[0] - const waitTime = this.windowMs - (now - oldestRequest) + 10 // +10ms buffer - await new Promise(resolve => setTimeout(resolve, waitTime)) - // Clean up again after waiting - this.requests = this.requests.filter(t => Date.now() - t < this.windowMs) - } - - this.requests.push(Date.now()) - } -} - -/** - * Create a Customer.io Analytics client - */ -function createCioClient(batchSize) { - const apiKey = process.env.CUSTOMER_IO_API_KEY - - if (!apiKey) { - throw new Error( - 'CUSTOMER_IO_API_KEY environment variable is required. ' + - 'Set it to your Customer.io CDP API key.' - ) - } - - return new Analytics({ - writeKey: apiKey, - host: 'https://cdp.customer.io', - maxEventsInBatch: batchSize, - }) -} - -/** - * Convert a CSV row to a Customer.io identify payload - */ -function parseOptionalBoolean(value) { - if (value == null || value === '') { - return undefined - } - - const normalized = String(value).trim().toLowerCase() - if (normalized === 'true') { - return true - } - if (normalized === 'false') { - return false - } - - return undefined -} - -function parseOptionalInt(value) { - if (value == null || value === '') { - return undefined - } - - const parsed = parseInt(value, 10) - return Number.isNaN(parsed) ? undefined : parsed -} - -function getFirstDefinedValue(row, columnNames) { - for (const columnName of columnNames) { - if (Object.prototype.hasOwnProperty.call(row, columnName)) { - return row[columnName] - } - } - - return undefined -} - -function rowToIdentifyPayload(row) { - const email = getFirstDefinedValue(row, ['email']) - if (!email) { - return null - } - - const traits = {} - const overleafUserId = getFirstDefinedValue(row, [ - 'user_id', - 'userId', - 'overleafId', - ]) - - if (email) { - traits.email = email - } - - if (overleafUserId) { - traits.overleaf_id = overleafUserId - } - - // Add created_at if present (keep as unix timestamp) - const createdAtValue = getFirstDefinedValue(row, ['created_at']) - if (createdAtValue) { - const createdAt = parseOptionalInt(createdAtValue) - if (createdAt !== undefined) { - traits.created_at = createdAt - } - } - - // Add subscription status fields when present (from export_active_subscription_users_csv.mjs) - const stringTraitMappings = [ - { - columnNames: ['plan_type', 'planType'], - traitName: 'plan_type', - }, - { - columnNames: ['display_plan_type', 'displayPlanType'], - traitName: 'display_plan_type', - }, - { - columnNames: ['pre_migration_plan_type', 'preMigrationPlanType'], - traitName: 'pre_migration_plan_type', - }, - { - columnNames: [ - 'pre_migration_display_plan_type', - 'preMigrationDisplayPlanType', - ], - traitName: 'pre_migration_display_plan_type', - }, - { - columnNames: ['plan_term', 'planTerm', 'plan_term_label'], - traitName: 'plan_term', - }, - { - columnNames: ['ai_plan', 'aiPlan'], - traitName: 'ai_plan', - }, - { - columnNames: ['ai_plan_term', 'aiPlanTerm', 'ai_plan_term_label'], - traitName: 'ai_plan_term', - }, - { - columnNames: ['group_role', 'groupRole'], - traitName: 'group_role', - }, - ] - - for (const { columnNames, traitName } of stringTraitMappings) { - const value = getFirstDefinedValue(row, columnNames) - if (value) { - traits[traitName] = value - } - } - - const nextRenewalDateValue = getFirstDefinedValue(row, [ - 'next_renewal_date', - 'nextRenewalDate', - ]) - if (nextRenewalDateValue !== undefined) { - if (nextRenewalDateValue === '') { - traits.next_renewal_date = '' - } else { - const nextRenewalDate = parseOptionalInt(nextRenewalDateValue) - if (nextRenewalDate !== undefined) { - traits.next_renewal_date = nextRenewalDate - } - } - } - - const expiryDateValue = getFirstDefinedValue(row, [ - 'expiry_date', - 'expiryDate', - ]) - if (expiryDateValue !== undefined) { - if (expiryDateValue === '') { - traits.expiry_date = '' - } else { - const expiryDate = parseOptionalInt(expiryDateValue) - if (expiryDate !== undefined) { - traits.expiry_date = expiryDate - } - } - } - - const groupAiEnabledValue = getFirstDefinedValue(row, [ - 'group_ai_enabled', - 'groupAIEnabled', - ]) - if (groupAiEnabledValue !== undefined) { - const groupAiEnabled = parseOptionalBoolean(groupAiEnabledValue) - if (groupAiEnabled !== undefined) { - traits.group_ai_enabled = groupAiEnabled - } - } - - // Add subscription preferences - for (const key of Object.keys(row)) { - if (key.startsWith('cio_subscription_preferences.topics.')) { - if (row[key] === 'true') { - traits[key] = true - } - } - } - - // Add labsExperiments if present - if (row.labsExperiments) { - try { - traits.labsExperiments = JSON.parse(row.labsExperiments) - } catch { - // If it's not valid JSON, store as-is - traits.labsExperiments = row.labsExperiments - } - } - - return { - // Prefer stable Overleaf user id when available, otherwise fall back to email - userId: overleafUserId || email, - email, - traits, - } -} - -/** - * Create a CSV parser stream - */ -function createCsvParser(inputPath) { - return createReadStream(inputPath).pipe( - csv.parse({ - columns: true, - skip_empty_lines: true, - relax_column_count: true, - }) - ) -} - -/** - * Main script function - */ -function main() { - const argv = minimist(process.argv.slice(2), { - string: ['input', 'batch-size', 'skip'], - boolean: ['commit', 'help'], - alias: { - i: 'input', - h: 'help', - }, - }) - - if (argv.help) { - usage() - } - - const inputPath = argv.input - const commit = argv.commit - const dryRun = !commit - const batchSize = parseInt(argv['batch-size'], 10) || DEFAULT_BATCH_SIZE - const skipRows = parseInt(argv.skip, 10) || 0 - - if (!inputPath) { - console.error('Error: --input is required') - usage() - } - - if (!fs.existsSync(inputPath)) { - console.error(`Error: Input file not found: ${inputPath}`) - process.exit(1) - } - - if (commit && !process.env.CUSTOMER_IO_API_KEY) { - console.error( - 'Error: CUSTOMER_IO_API_KEY environment variable is required when using --commit' - ) - process.exit(1) - } - - scriptRunner( - async trackProgress => { - await trackProgress('Starting batch identify to Customer.io...') - await trackProgress(`Input: ${inputPath}`) - await trackProgress(`Batch size limit: ${batchSize}`) - if (skipRows > 0) { - await trackProgress(`Skipping first ${skipRows} rows`) - } - if (dryRun) { - await trackProgress('DRY RUN MODE - no requests will be sent') - } - - const rateLimiter = new RateLimiter( - RATE_LIMIT_REQUESTS, - RATE_LIMIT_WINDOW_MS - ) - const client = dryRun ? null : createCioClient(batchSize) - - // Listen to the 'error' event - if (!dryRun) { - client.on('error', err => { - console.error('cdp-analytics-node error occurred:') - console.error('Code:', err.code) - console.error('Reason:', err.reason) - if (err.ctx) { - console.error('Context:', err.ctx) - } - }) - } - - let rowNumber = 0 - let processedCount = 0 - let skippedCount = 0 - let lastProgressLog = 0 - let loggedFirstIdentifyPayload = false - - const parser = createCsvParser(inputPath) - - try { - for await (const row of parser) { - rowNumber++ - - // Skip rows if resuming - if (rowNumber <= skipRows) { - continue - } - - const payload = rowToIdentifyPayload(row) - if (!payload) { - skippedCount++ - continue - } - - if (dryRun && !loggedFirstIdentifyPayload) { - await trackProgress( - `First identify payload (dry run): ${JSON.stringify(payload)}` - ) - loggedFirstIdentifyPayload = true - } - - if (!dryRun) { - await rateLimiter.waitIfNeeded() - client.identify(payload) - } - - processedCount++ - - // Log progress periodically - if (processedCount - lastProgressLog >= PROGRESS_LOG_INTERVAL) { - await trackProgress( - `Progress: row ${rowNumber}, sent ${processedCount} requests (${skippedCount} skipped)` - ) - lastProgressLog = processedCount - } - } - } catch (error) { - await trackProgress( - `ERROR at row ${rowNumber}: ${error.message}. Resume with --skip ${rowNumber - 1}` - ) - throw error - } - - if (!dryRun && client) { - await trackProgress('Flushing remaining requests...') - await client.closeAndFlush() - } - - await trackProgress( - `Completed: processed ${processedCount} rows (${skippedCount} skipped due to missing email)` - ) - if (dryRun) { - await trackProgress( - `DRY RUN complete - would have sent ${processedCount} identify requests` - ) - } - process.exit(0) - }, - { inputPath, dryRun, batchSize, skipRows } - ).catch(err => { - console.error(err) - process.exit(1) - }) -} - -main() diff --git a/services/web/scripts/bench_bcrypt.mjs b/services/web/scripts/bench_bcrypt.mjs deleted file mode 100644 index 1aee1b583d..0000000000 --- a/services/web/scripts/bench_bcrypt.mjs +++ /dev/null @@ -1,122 +0,0 @@ -import minimist from 'minimist' -import { promisify } from 'node:util' -import bcrypt from 'bcrypt' -import { promiseMapWithLimit } from '@overleaf/promise-utils' -// https://github.com/import-js/eslint-plugin-import/issues/1810 -// eslint-disable-next-line import/no-unresolved -import * as csv from 'csv/sync' - -const bcryptCompare = promisify(bcrypt.compare) -const bcryptGenSalt = promisify(bcrypt.genSalt) -const bcryptHash = promisify(bcrypt.hash) - -const argv = minimist(process.argv.slice(2), { - string: ['major', 'minor', 'concurrency', 'samples', 'password'], - bool: ['hash', 'compare', 'verbose', 'table', 'csv'], - default: { - major: '12,13,14,15', - minor: 'a', - concurrency: '1,2,4,10,20', - samples: 100, - password: 'x'.repeat(72), - hash: true, - compare: true, - verbose: true, - table: true, - csv: true, - }, -}) - -const SAMPLES = parseInt(argv.samples, 10) -const STATS = [] - -function asListOfInt(s) { - return s.split(',').map(x => parseInt(x, 10)) -} - -async function computeHash(rounds, minor) { - const salt = await bcryptGenSalt(rounds, minor) - return await bcryptHash(argv.password, salt) -} - -async function sample(concurrency, fn) { - const stats = await promiseMapWithLimit( - concurrency, - new Array(SAMPLES).fill(0), - async () => { - const t0 = process.hrtime.bigint() - await fn() - const t1 = process.hrtime.bigint() - return Number(t1 - t0) / 1e6 - } - ) - const sum = stats.reduce((a, b) => a + b, 0) - const avg = sum / SAMPLES - stats.sort((a, b) => a - b) - const median = stats[Math.ceil(SAMPLES / 2)] - const p95 = stats[Math.ceil(SAMPLES * 0.95)] - const min = stats[0] - const max = stats[stats.length - 1] - return Object.fromEntries( - Object.entries({ - min, - avg, - median, - p95, - max, - }).map(([key, value]) => [key, Math.ceil(value) + 'ms']) - ) -} - -async function run(rounds, minor, concurrency) { - if (argv.hash) { - const stats = await sample(concurrency, async () => { - await computeHash(rounds, minor) - }) - STATS.push({ - kind: 'hash', - rounds, - concurrency, - ...stats, - }) - if (argv.verbose) console.log(STATS[STATS.length - 1]) - } - if (argv.compare) { - const hashedPassword = await computeHash(rounds, minor) - const stats = await sample(concurrency, async () => { - await bcryptCompare(argv.password, hashedPassword) - }) - STATS.push({ - kind: 'compare', - rounds, - concurrency, - ...stats, - }) - if (argv.verbose) console.log(STATS[STATS.length - 1]) - } -} - -async function main() { - for (const rounds of asListOfInt(argv.major)) { - for (const minor of argv.minor.split(',')) { - for (const concurrency of asListOfInt(argv.concurrency)) { - await run(rounds, minor, concurrency) - } - } - } - - STATS.forEach(s => { - s.samples = SAMPLES - }) - - if (argv.table) console.table(STATS) - if (argv.csv) console.log(csv.stringify(STATS, { header: true })) -} - -try { - await main() - process.exit(0) -} catch (error) { - console.error(error) - process.exit(1) -} diff --git a/services/web/scripts/check_institution_users.mjs b/services/web/scripts/check_institution_users.mjs deleted file mode 100644 index 7b37f9c6f1..0000000000 --- a/services/web/scripts/check_institution_users.mjs +++ /dev/null @@ -1,26 +0,0 @@ -import InstitutionsManager from '../app/src/Features/Institutions/InstitutionsManager.mjs' -import { ensureRunningOnMongoSecondaryWithTimeout } from './helpers/env_variable_helper.mjs' - -// ScriptRunner can not be used when using this assertion -ensureRunningOnMongoSecondaryWithTimeout(300000) - -const institutionId = parseInt(process.argv[2]) -if (isNaN(institutionId)) throw new Error('No institution id') -console.log('Checking users of institution', institutionId) -const emitNonProUserIds = process.argv.includes('--emit-non-pro-user-ids') - -async function main() { - const usersSummary = await InstitutionsManager.promises.checkInstitutionUsers( - institutionId, - emitNonProUserIds - ) - console.log(usersSummary) - process.exit() -} - -try { - await main() -} catch (error) { - console.error(error) - process.exit(1) -} diff --git a/services/web/scripts/check_overleafModuleImports.mjs b/services/web/scripts/check_overleafModuleImports.mjs deleted file mode 100644 index 446ce317da..0000000000 --- a/services/web/scripts/check_overleafModuleImports.mjs +++ /dev/null @@ -1,26 +0,0 @@ -import CE_CONFIG from '../config/settings.defaults.js' -import PRO_CONFIG from '../config/settings.overrides.server-pro.js' -import SAAS_CONFIG from '../config/settings.webpack.js' - -function getOverleafModuleImports(settings) { - return Object.keys(settings.overleafModuleImports).sort().join(',') -} - -function main() { - const CE = getOverleafModuleImports(CE_CONFIG) - const PRO = getOverleafModuleImports(CE_CONFIG.mergeWith(PRO_CONFIG)) - const SAAS = getOverleafModuleImports(CE_CONFIG.mergeWith(SAAS_CONFIG)) - - if (CE !== PRO) { - throw new Error( - 'settings.defaults is missing overleafModuleImports defined in settings.overrides.server-pro' - ) - } - if (CE !== SAAS) { - throw new Error( - 'settings.defaults is missing overleafModuleImports defined in settings.webpack' - ) - } -} - -main() diff --git a/services/web/scripts/check_saml_emails.mjs b/services/web/scripts/check_saml_emails.mjs deleted file mode 100644 index 3043e5bd64..0000000000 --- a/services/web/scripts/check_saml_emails.mjs +++ /dev/null @@ -1,20 +0,0 @@ -import SAMLEmailBatchCheck from '../modules/saas-authentication/app/src/SAML/SAMLEmailBatchCheck.mjs' -import { ensureRunningOnMongoSecondaryWithTimeout } from './helpers/env_variable_helper.mjs' - -// ScriptRunner can not be used when using this assertion -ensureRunningOnMongoSecondaryWithTimeout(300000) - -const startInstitutionId = parseInt(process.argv[2]) -const emitDetailedData = process.argv.includes('--detailed-data') - -try { - const result = await SAMLEmailBatchCheck.promises.checkEmails( - startInstitutionId, - emitDetailedData - ) - console.table(result) - process.exit() -} catch (error) { - console.error(error) - process.exit(1) -} diff --git a/services/web/scripts/clear_feedback_collection.mjs b/services/web/scripts/clear_feedback_collection.mjs deleted file mode 100644 index 694c30c7ec..0000000000 --- a/services/web/scripts/clear_feedback_collection.mjs +++ /dev/null @@ -1,41 +0,0 @@ -/* Clear feedback collection before a cutoff date - * - * Usage - * node scripts/clear_feedback_collection.mjs 2022-11-01 # dry run mode - * DRY_RUN=false node scripts/clear_feedback_collection.mjs 2022-11-01 # deletion mode - */ - -import { db, ObjectId } from '../app/src/infrastructure/mongodb.mjs' -import { fileURLToPath } from 'node:url' - -const runScript = async (timestamp, dryRun) => { - const t = new Date(timestamp) - if (isNaN(t)) { - throw new Error('invalid date ' + timestamp) - } - const cutoffId = ObjectId.createFromTime(t / 1000) - console.log('deleting all feedback entries before', t, '=>', cutoffId) - const cursor = db.feedbacks.find({ _id: { $lt: cutoffId } }) - for await (const entry of cursor) { - console.log('deleting', entry._id) - if (dryRun) { - console.log('skipping in dry run mode') - continue - } - await db.feedbacks.deleteOne({ _id: entry._id }) - } -} - -if (fileURLToPath(import.meta.url) === process.argv[1]) { - // we are in the root module, which means that we're running as a script - const timestamp = process.env.CUTOFF_TIMESTAMP || process.argv[2] - const dryRun = process.env.DRY_RUN !== 'false' - runScript(timestamp, dryRun) - .then(() => process.exit()) - .catch(err => { - console.error(err) - process.exit(1) - }) -} - -export default runScript diff --git a/services/web/scripts/clear_institution_notifications.mjs b/services/web/scripts/clear_institution_notifications.mjs deleted file mode 100644 index ed7490b73a..0000000000 --- a/services/web/scripts/clear_institution_notifications.mjs +++ /dev/null @@ -1,50 +0,0 @@ -import { promisify } from 'node:util' -import InstitutionsManager from '../app/src/Features/Institutions/InstitutionsManager.mjs' -import { fileURLToPath } from 'node:url' -import { scriptRunner } from './lib/ScriptRunner.mjs' -const sleep = promisify(setTimeout) - -async function main() { - const institutionId = parseInt(process.argv[2]) - if (isNaN(institutionId)) throw new Error('No institution id') - const dryRun = process.argv.includes('--dry-run') - - console.log('Deleting notifications of institution', institutionId) - - const preview = - await InstitutionsManager.promises.clearInstitutionNotifications( - institutionId, - true - ) - console.log('--- Preview ---') - console.log(JSON.stringify(preview, null, 4)) - console.log('---------------') - - if (dryRun) { - console.log('Exiting early due to --dry-run flag') - return - } - - console.log('Exit in the next 10s in case these numbers are off.') - await sleep(10 * 1000) - - const cleared = - await InstitutionsManager.promises.clearInstitutionNotifications( - institutionId, - false - ) - console.log('--- Cleared ---') - console.log(JSON.stringify(cleared, null, 4)) - console.log('---------------') -} - -if (fileURLToPath(import.meta.url) === process.argv[1]) { - try { - await scriptRunner(main) - console.log('Done.') - process.exit(0) - } catch (error) { - console.error(error) - process.exit(1) - } -} diff --git a/services/web/scripts/clear_sessions_2fa.mjs b/services/web/scripts/clear_sessions_2fa.mjs deleted file mode 100644 index 1d9038727d..0000000000 --- a/services/web/scripts/clear_sessions_2fa.mjs +++ /dev/null @@ -1,84 +0,0 @@ -import { promisify, promiseMapWithLimit } from '@overleaf/promise-utils' -import UserSessionsRedis from '../app/src/Features/User/UserSessionsRedis.mjs' -import minimist from 'minimist' -import { scriptRunner } from './lib/ScriptRunner.mjs' - -const rClient = UserSessionsRedis.client() - -const args = minimist(process.argv.slice(2)) -const CURSOR = args.cursor -const COMMIT = args.commit === 'true' -const CONCURRENCY = parseInt(args.concurrency, 10) || 50 -const LOG_EVERY_IN_S = parseInt(args['log-every-in-s'], 10) || 5 - -function shouldDelete(session) { - if (session.twoFactorAuthenticationPendingUser) { - // twoFactorAuthenticationPendingUserId migration - return true - } - - // default: keep - return false -} - -async function processSession(key) { - if (!key || !key.startsWith('sess:')) { - throw new Error(`unexpected session key: ${key}`) - } - - const blob = await rClient.get(key) - if (!blob) return false // expired or deleted - const session = JSON.parse(blob) - - if (shouldDelete(session)) { - const deleteLabel = COMMIT ? 'delete' : 'would delete' - console.warn(deleteLabel, key) - if (COMMIT) { - await rClient.del(key) - } - return true - } - return false -} - -async function main() { - console.warn({ COMMIT, CONCURRENCY, CURSOR, LOG_EVERY_IN_S }) - console.warn('starting in 10s') - await promisify(setTimeout)(10_000) - - let processed = 0 - let deleted = 0 - function logProgress() { - const deletedLabel = COMMIT ? 'deleted' : 'would have deleted' - console.log( - `processed ${processed} | ${deletedLabel} ${deleted} | cursor ${cursor}` - ) - } - - let cursor = CURSOR - let lastLog = 0 - while (cursor !== '0') { - let keys - ;[cursor, keys] = await rClient.scan(cursor || 0, 'MATCH', 'sess:*') - - const results = await promiseMapWithLimit(CONCURRENCY, keys, processSession) - processed += keys.length - for (const r of results) { - if (r) deleted++ - } - if (Date.now() - lastLog >= LOG_EVERY_IN_S * 1000) { - logProgress() - lastLog = Date.now() - } - } - logProgress() - console.log('Done.') - await rClient.disconnect() -} - -try { - await scriptRunner(main) -} catch (error) { - console.error(error) - process.exit(1) -} diff --git a/services/web/scripts/count_encrypted_access_tokens.mjs b/services/web/scripts/count_encrypted_access_tokens.mjs deleted file mode 100644 index b840a75ac1..0000000000 --- a/services/web/scripts/count_encrypted_access_tokens.mjs +++ /dev/null @@ -1,74 +0,0 @@ -import { - db, - READ_PREFERENCE_SECONDARY, -} from '../app/src/infrastructure/mongodb.mjs' -import _ from 'lodash' -import { formatTokenUsageStats } from '@overleaf/access-token-encryptor/scripts/helpers/format-usage-stats.js' -import { ensureMongoTimeout } from './helpers/env_variable_helper.mjs' -import { scriptRunner } from './lib/ScriptRunner.mjs' - -if (!process.env.MONGO_SOCKET_TIMEOUT) { - const TEN_MINUTES = 1000 * 60 * 10 - ensureMongoTimeout(TEN_MINUTES) -} - -const CASES = { - users: { - dropbox: 'dropbox.access_token_oauth2.encrypted', - zotero: 'refProviders.zotero.encrypted', - mendeley: 'refProviders.mendeley.encrypted', - }, - githubSyncUserCredentials: { - github: 'auth_token_encrypted', - }, -} - -async function count(collectionName, paths) { - const collection = db[collectionName] - const stats = {} - - const projection = { _id: 0 } - for (const path of Object.values(paths)) { - projection[path] = 1 - } - - const cursor = collection.find( - {}, - { - readPreference: READ_PREFERENCE_SECONDARY, - projection, - } - ) - for await (const doc of cursor) { - for (const [name, path] of Object.entries(paths)) { - const blob = _.get(doc, path) - if (!blob) continue - // Schema: LABEL-VERSION:SALT:CIPHERTEXT:IV - const [label] = blob.split(':') - let [, version] = label.split('-') - version = version || 'v2' - - const key = [name, version, collectionName, path, label].join(':') - stats[key] = (stats[key] || 0) + 1 - } - } - return stats -} - -async function main() { - const STATS = {} - for (const [collectionName, paths] of Object.entries(CASES)) { - const stats = await count(collectionName, paths) - Object.assign(STATS, stats) - } - - formatTokenUsageStats(STATS) -} - -try { - await scriptRunner(main) - process.exit(0) -} catch (error) { - console.error(error) - process.exit(1) -} diff --git a/services/web/scripts/create_oauth_personal_access_token.mjs b/services/web/scripts/create_oauth_personal_access_token.mjs deleted file mode 100644 index 315ba59be4..0000000000 --- a/services/web/scripts/create_oauth_personal_access_token.mjs +++ /dev/null @@ -1,31 +0,0 @@ -// Script to create a Personal Access Token for a given user -// Example: -// node scripts/create_oauth_personal_access_token.mjs --user-id=643e5b240dc50c83b5bf1127 - -import parseArgs from 'minimist' - -import OAuthPersonalAccessTokenManager from '../modules/oauth2-server/app/src/OAuthPersonalAccessTokenManager.mjs' - -const argv = parseArgs(process.argv.slice(2), { - string: ['user-id'], -}) - -const userId = argv['user-id'] - -if (!userId) { - console.error('Missing --user-id argument') - process.exit(1) -} - -async function createPersonalAccessToken() { - const accessToken = await OAuthPersonalAccessTokenManager.createToken(userId) - console.log('Personal Access Token: ' + accessToken) -} - -try { - await createPersonalAccessToken() - process.exit() -} catch (error) { - console.error(error) - process.exit(1) -} diff --git a/services/web/scripts/delete-duplicate-splittest-versions/delete_test_dupes.mjs b/services/web/scripts/delete-duplicate-splittest-versions/delete_test_dupes.mjs deleted file mode 100644 index 3ad6f9ce16..0000000000 --- a/services/web/scripts/delete-duplicate-splittest-versions/delete_test_dupes.mjs +++ /dev/null @@ -1,91 +0,0 @@ -import { db } from '../../app/src/infrastructure/mongodb.mjs' -import minimist from 'minimist' - -const argv = minimist(process.argv.slice(2)) -const commit = argv.commit !== undefined - -if (!commit) { - console.log('DOING DRY RUN. TO SAVE CHANGES PASS --commit') -} - -async function getDupes(commit) { - const entries = await db.splittests.aggregate([ - { - $match: { - archived: { $eq: true }, - }, - }, - { $unwind: '$versions' }, - { - $group: { - // Group by fields to match on (a,b) - _id: { - _id: '$_id', - name: '$name', - creationDate: '$version.creationDate', - }, - - // Count number of matching docs for the group - count: { $sum: 1 }, - - // Save the _id for matching docs - docs: { $push: '$_id' }, - }, - }, - - // Limit results to duplicates (more than 1 match) - { - $match: { - count: { $gt: 1 }, - }, - }, - ]) - - let entry - const removed = [] - while ((entry = await entries.next())) { - const name = entry._id.name - const test = await db.splittests.findOne({ name }) - - if (hasArchiveDupe(test.versions)) { - removed.push(test.name) - removeLastVersion(test, commit) - } - } - const message = commit - ? `removed dupes from ${removed.length} feature flags` - : `planning to remove dupes from ${removed.length} feature flags` - console.info(message, removed) - - console.log('DONE') - process.exit() -} - -function hasArchiveDupe(versions) { - const last = versions.length - 1 - // guard in case we somehow get smthn with only one version here flagged as having a dupe - if (last < 2) return false - // need to string compare dates, as otherwise will compare the isoDate objects (diff objs so not equal) - return ( - versions[last].createdAt.toString() === - versions[last - 1].createdAt.toString() - ) -} - -function removeLastVersion(test, commit) { - const name = test.name - const numVersions = test.versions.length - - if (name && numVersions > 1) { - const lastVersion = test.versions[numVersions - 1].versionNumber - console.log(`removing test ${test.name} version ${lastVersion}`) - if (commit) { - db.splittests.updateOne( - { name }, - { $pull: { versions: { versionNumber: lastVersion } } } - ) - } - } -} - -getDupes(commit) diff --git a/services/web/scripts/delete_old_writefull_refresh_tokens.mjs b/services/web/scripts/delete_old_writefull_refresh_tokens.mjs deleted file mode 100644 index ced073617c..0000000000 --- a/services/web/scripts/delete_old_writefull_refresh_tokens.mjs +++ /dev/null @@ -1,38 +0,0 @@ -import minimist from 'minimist' -import { scriptRunner } from './lib/ScriptRunner.mjs' -import { db } from '../app/src/infrastructure/mongodb.mjs' - -const argv = minimist(process.argv.slice(2)) - -async function main() { - const { expirationDate, commit } = argv - const cutOffDate = new Date(expirationDate) - const { _id: writefullId } = await db.oauthApplications.findOne({ - id: 'writefull', - }) - const count = await db.oauthAccessTokens.countDocuments( - { - accessTokenExpiresAt: { $lte: cutOffDate }, - oauthApplication_id: writefullId, - }, - { readPreference: 'secondaryPreferred' } - ) - console.log(`${count} access tokens expired before ${cutOffDate}`) - if (commit) { - await db.oauthAccessTokens.deleteMany({ - oauthApplication_id: writefullId, - accessTokenExpiresAt: { $lte: cutOffDate }, - }) - } else { - console.log('use --commit to delete expired tokens') - } -} - -try { - await scriptRunner(main) - console.log('Done.') - process.exit(0) -} catch (error) { - console.error({ error }) - process.exit(1) -} diff --git a/services/web/scripts/delete_subscriptions.mjs b/services/web/scripts/delete_subscriptions.mjs deleted file mode 100644 index 6fb4610614..0000000000 --- a/services/web/scripts/delete_subscriptions.mjs +++ /dev/null @@ -1,47 +0,0 @@ -import { Subscription } from '../app/src/models/Subscription.mjs' -import SubscriptionUpdater from '../app/src/Features/Subscription/SubscriptionUpdater.mjs' -import minimist from 'minimist' -import mongodb from 'mongodb-legacy' - -const { ObjectId } = mongodb - -const run = async () => { - for (const id of ids) { - console.log('id', id) - const subscription = await Subscription.findOne({ _id: new ObjectId(id) }) - await SubscriptionUpdater.promises.deleteSubscription( - subscription, - deleterData - ) - console.log('Deleted subscription', id) - } -} - -let ids, deleterData -const setup = () => { - const argv = minimist(process.argv.slice(2)) - ids = argv.ids - if (!ids) { - console.error('No ids given') - process.exit(1) - } - ids = ids.split(',') - - const deleterId = argv.deleterId - if (!deleterId) { - console.error('No deleterId given') - process.exit(1) - } - - deleterData = { id: new ObjectId(deleterId) } -} - -setup() - -try { - await run() - process.exit(0) -} catch (err) { - console.error('Aiee, something went wrong!', err) - process.exit(1) -} diff --git a/services/web/scripts/devcontainer_setup.mjs b/services/web/scripts/devcontainer_setup.mjs deleted file mode 100644 index 612a085dd8..0000000000 --- a/services/web/scripts/devcontainer_setup.mjs +++ /dev/null @@ -1,174 +0,0 @@ -// @ts-check -import Settings from '@overleaf/settings' -import { waitForDb, db, ObjectId } from '../app/src/infrastructure/mongodb.mjs' -import GracefulShutdown from '../app/src/infrastructure/GracefulShutdown.mjs' -import UserRegistrationHandler from '../app/src/Features/User/UserRegistrationHandler.mjs' -import { Subscription } from '../app/src/models/Subscription.mjs' -import minimist from 'minimist' -import { - createProjectWithOldHistoryId, - provisionSplitTests, -} from './e2e_test_setup.mjs' -import { Project } from '../app/src/models/Project.mjs' -import OError from '@overleaf/o-error' -import fs from 'node:fs' -import Path from 'node:path' -import { fileURLToPath } from 'node:url' -import crypto from 'node:crypto' - -const MONOREPO = Path.dirname( - Path.dirname(Path.dirname(Path.dirname(fileURLToPath(import.meta.url)))) -) - -const { email: USER_EMAIL, password: PASSWORD } = minimist( - process.argv.slice(2), - { string: ['email', 'password'] } -) - -/** - * @param {string} email - * @param {Object} opts - * @param {boolean?} opts.isAdmin - * @param {boolean?} opts.forceProfessional - * @return {Promise} - */ -async function createUser( - email, - opts = { isAdmin: false, forceProfessional: false } -) { - const { isAdmin = false, forceProfessional = false } = opts - /** @type {import('mongodb-legacy').ObjectId} */ - let userId - try { - const user = await UserRegistrationHandler.promises.registerNewUser({ - email, - password: PASSWORD, - analyticsId: crypto.randomUUID(), - }) - userId = user._id - } catch (err) { - if ( - err instanceof OError && - err.message.includes('EmailAlreadyRegistered') && - err.info && - 'userId' in err.info && - err.info.userId instanceof ObjectId - ) { - userId = err.info.userId - } else { - throw err - } - } - /** @type {string[]} */ - let adminRoles = [] - if (isAdmin) { - adminRoles = ['engineering'] - } - await db.users.updateOne( - { _id: userId }, - { - $set: { - // Set admin flag. - isAdmin, - adminRoles, - // disable AI features, does not work with custom GH Code Spaces domain. - 'aiFeatures.enabled': false, - // Override features. - ...(forceProfessional - ? { - features: Settings.features.professional, - featuresOverrides: [{ features: Settings.features.professional }], - } - : {}), - }, - } - ) - return userId.toString() -} - -async function provisionUsers() { - await Promise.all([ - createUser(USER_EMAIL, { isAdmin: true, forceProfessional: true }), - createUser('admin@overleaf.com', { - isAdmin: true, - forceProfessional: true, - }), - createUser('free@overleaf.com'), - createUser('premium@overleaf.com').then(async userId => { - const subscription = new Subscription({ - admin_id: userId, - member_ids: [userId], - manager_ids: [userId], - planCode: 'professional', - customAccount: true, - }) - try { - await subscription.save() - } catch (err) { - if (!isAlreadyExistsErr(err)) throw err // ignore already exists error - } - }), - createUser('group-owner@overleaf.com').then(async userId => { - const memberId = await createUser('group-member@overleaf.com') - const subscription = new Subscription({ - admin_id: userId, - member_ids: [memberId], - manager_ids: [userId], - groupPlan: true, - planCode: 'group_professional_10_enterprise', - membersLimit: 10, - teamName: 'Test Team', - customAccount: true, - }) - try { - await subscription.save() - } catch (err) { - if (!isAlreadyExistsErr(err)) throw err // ignore already exists error - } - }), - createUser('with-old-history@overleaf.com', { - isAdmin: true, - forceProfessional: true, - }).then(async userId => { - const projectName = 'old history id (Uses v1 postgres storage)' - const ownedProjects = await Project.find( - { owner_ref: userId }, - { name: true } - ).exec() - for (const project of ownedProjects) { - if (project.name === projectName) return - } - await createProjectWithOldHistoryId(userId, projectName) - }), - ]) -} - -/** - * @param {unknown} err - * @return {boolean} - */ -function isAlreadyExistsErr(err) { - return err instanceof Error && 'code' in err && err.code === 11000 -} - -async function main() { - if (process.env.NODE_ENV !== 'development') { - throw new Error('only available in dev-env') - } - const extraSplitTests = JSON.parse( - await fs.promises.readFile( - Path.join(MONOREPO, '.devcontainer/extra-split-tests.json'), - 'utf-8' - ) - ) - await waitForDb() - await Promise.all([ - provisionUsers(), - provisionSplitTests(true, extraSplitTests), - ]) -} - -if (import.meta.main) { - await main() - await GracefulShutdown.gracefulShutdown() -} diff --git a/services/web/scripts/e2e_test_setup.mjs b/services/web/scripts/e2e_test_setup.mjs deleted file mode 100644 index 46f9f50f76..0000000000 --- a/services/web/scripts/e2e_test_setup.mjs +++ /dev/null @@ -1,231 +0,0 @@ -import fs from 'node:fs' -import Path from 'node:path' -import { fileURLToPath } from 'node:url' -import { promiseMapWithLimit } from '@overleaf/promise-utils' -import Settings from '@overleaf/settings' -import { connectionPromise, db } from '../app/src/infrastructure/mongodb.mjs' -import GracefulShutdown from '../app/src/infrastructure/GracefulShutdown.mjs' -import ProjectDeleter from '../app/src/Features/Project/ProjectDeleter.mjs' -import SplitTestManager from '../app/src/Features/SplitTests/SplitTestManager.mjs' -import UserDeleter from '../app/src/Features/User/UserDeleter.mjs' -import UserRegistrationHandler from '../app/src/Features/User/UserRegistrationHandler.mjs' -import HistoryManager from '../app/src/Features/History/HistoryManager.mjs' -import ProjectCreationHandler from '../app/src/Features/Project/ProjectCreationHandler.mjs' -import crypto from 'node:crypto' - -const MONOREPO = Path.dirname( - Path.dirname(Path.dirname(Path.dirname(fileURLToPath(import.meta.url)))) -) - -/** - * @param {string} email - * @return {Promise} - */ -async function createUser(email) { - const user = await UserRegistrationHandler.promises.registerNewUser({ - email, - password: process.env.CYPRESS_DEFAULT_PASSWORD, - analyticsId: crypto.randomUUID(), - }) - const features = email.startsWith('free+') - ? Settings.defaultFeatures - : Settings.features.professional - const isAdmin = email.startsWith('admin+') - let adminRoles = [] - if (email.startsWith('admin+finance')) { - adminRoles = ['finance'] - } else if (isAdmin) { - adminRoles = ['engineering'] - } - await db.users.updateOne( - { _id: user._id }, - { - $set: { - // Set admin flag. - isAdmin, - adminRoles, - // Disable spell-checking for performance and flakiness reasons. - 'ace.spellCheckLanguage': '', - // Override features. - features, - featuresOverrides: [{ features }], - // disable AI features - 'aiFeatures.enabled': false, - }, - } - ) - return user._id.toString() -} - -/** - * @param {string} email - * @return {Promise} - */ -async function deleteUser(email) { - const user = await db.users.findOne({ email }) - if (!user) return - // Delete the subscriptions of the user - await db.subscriptions.deleteMany({ admin_id: user._id }) - // Soft delete the user. - await UserDeleter.promises.deleteUser(user._id, { - force: true, - ipAddress: '0.0.0.0', - }) - // Hard-delete the users projects. - const projects = await db.deletedProjects - .find( - { 'deleterData.deletedProjectOwnerId': user._id }, - { projection: { 'deleterData.deletedProjectId': 1 } } - ) - .toArray() - await promiseMapWithLimit( - 10, - projects.map(p => p.deleterData.deletedProjectId), - ProjectDeleter.promises.expireDeletedProject - ) - // Hard-delete the user. - await UserDeleter.promises.expireDeletedUser(user._id) -} - -export async function createProjectWithOldHistoryId( - userId, - projectName = 'old history id' -) { - const historyId = parseInt( - await HistoryManager.promises.initializeProject(), - 10 - ) - await ProjectCreationHandler.promises.createExampleProject( - userId, - projectName, - { overleaf: { history: { id: historyId } } } - ) -} - -/** - * @param {string} email - * @return {Promise} - */ -async function provisionUser(email) { - if (!email.includes('+')) { - throw new Error( - `email=${email} should include the test suite name, e.g. user+project-sharing@example.com` - ) - } - await deleteUser(email) - const userId = await createUser(email) - - if (email === 'user+old-history-id@example.com') { - await createProjectWithOldHistoryId(userId) - } -} - -async function provisionUsers() { - const emails = Settings.recaptcha.trustedUsers - console.log(`> Provisioning ${emails.length} E2E users.`) - await promiseMapWithLimit(5, emails, provisionUser) -} - -async function purgeNewUsers() { - const users = await db.users - .find( - { email: Settings.recaptcha.trustedUsersRegex }, - { projection: { email: 1 } } - ) - .toArray() - console.log(`> Deleting ${users.length} newly created E2E users.`) - await promiseMapWithLimit( - 5, - users.map(user => user.email), - deleteUser - ) -} - -export async function provisionSplitTests(merge = false, extraSplitTests = []) { - const backup = Path.join( - MONOREPO, - 'backup', - 'split-tests', - new Date().toISOString() + '.json' - ) - console.log( - `> Backing up previous split-tests into ${backup}. You can import them again on https://www.dev-overleaf.com/admin/split-test via the [Import] button.` - ) - const splitTests = await SplitTestManager.getRuntimeTests() - await fs.promises.mkdir(Path.dirname(backup), { recursive: true }) - await fs.promises.writeFile( - backup, - JSON.stringify(splitTests.sort((a, b) => (a.name > b.name ? 1 : -1))) - ) - - // Imported from production via https://www.overleaf.com/admin/split-test -> "Copy all split tests" -> "Copy for E2E test setup" - const SPLIT_TESTS = JSON.parse( - await fs.promises.readFile( - Path.join(MONOREPO, 'tools/saas-e2e/split-tests.json'), - 'utf-8' - ) - ) - // Add WIP split test, we can update the JSON blob once this is in production - SPLIT_TESTS.push({ - name: 'zip-from-history', - versions: [ - { - versionNumber: 1, - createdAt: '2026-02-25T14:55:31.260Z', - active: true, - analyticsEnabled: false, - phase: 'release', - variants: [ - { - name: 'enabled', - rolloutPercent: 0, - rolloutStripes: [], - }, - ], - }, - ], - }) - console.log(`> Importing ${SPLIT_TESTS.length} split-tests from production.`) - if (merge) { - await SplitTestManager.mergeSplitTests(SPLIT_TESTS, false) - } else { - await SplitTestManager.replaceSplitTests(SPLIT_TESTS) - } - if (extraSplitTests.length > 0) { - await SplitTestManager.mergeSplitTests(extraSplitTests, false) - } -} - -async function checkNoTableScan() { - const client = await connectionPromise - const { notablescan } = await client - .db() - .admin() - .command({ getParameter: 1, notablescan: 1 }) - if (!notablescan) { - console.error() - console.error('!!! mongo is running without --notablescan') - console.error() - console.error('To fix this, either') - console.error('- run "internal$ bin/e2e_test_setup"') - console.error( - '- or add MONGO_EXTRA_ARGS=--notablescan in config/local.env and apply with "internal$ bin/up mongo"' - ) - console.error() - throw new Error('mongo is running without --notablescan') - } -} - -async function main() { - if (process.env.NODE_ENV !== 'development') { - throw new Error('only available in dev-env') - } - await checkNoTableScan() - - await Promise.all([purgeNewUsers(), provisionUsers(), provisionSplitTests()]) -} - -if (import.meta.main) { - await main() - await GracefulShutdown.gracefulShutdown() -} diff --git a/services/web/scripts/ensure_affiliations.mjs b/services/web/scripts/ensure_affiliations.mjs deleted file mode 100644 index 72d19d8c31..0000000000 --- a/services/web/scripts/ensure_affiliations.mjs +++ /dev/null @@ -1,52 +0,0 @@ -import { User } from '../app/src/models/User.mjs' -import UserController from '../app/src/Features/User/UserController.mjs' -import Logger from '@overleaf/logger' -import pLimit from 'p-limit' - -Logger.logger.level('error') -const CONCURRENCY = 10 -const failure = [] -const success = [] -console.log('Starting ensure affiliations') - -const query = { - 'emails.affiliationUnchecked': true, -} - -async function _handleEnsureAffiliation(user) { - try { - await UserController.ensureAffiliation(user) - console.log(`✔ ${user._id}`) - success.push(user._id) - } catch (error) { - failure.push(user._id) - console.log(`ERROR: ${user._id}`, error) - } -} - -async function getUsers() { - return User.find(query, { emails: 1 }).exec() -} - -async function run() { - const limit = pLimit(CONCURRENCY) - const users = await getUsers() - console.log(`Found ${users.length} users`) - await Promise.all( - users.map(user => limit(() => _handleEnsureAffiliation(user))) - ) - - console.log(`${success.length} successes`) - console.log(`${failure.length} failures`) - if (failure.length > 0) { - console.log('Failed to update:', failure) - } -} - -try { - await run() - process.exit() -} catch (error) { - console.log(error) - process.exit(1) -} diff --git a/services/web/scripts/esm-check-migration.mjs b/services/web/scripts/esm-check-migration.mjs deleted file mode 100644 index 52e3856b4a..0000000000 --- a/services/web/scripts/esm-check-migration.mjs +++ /dev/null @@ -1,300 +0,0 @@ -import fs from 'node:fs' -import path from 'node:path' -import minimist from 'minimist' - -const APP_CODE_PATH = ['app', 'modules', 'scripts', 'test'] - -// These have already been converted but don't have a `.mjs` extension -const converted = new Set([ - 'scripts/ukamf/check-certs.js', - 'scripts/ukamf/check-idp-metadata.js', - 'scripts/ukamf/metadata-processor.js', - 'scripts/ukamf/ukamf-db.js', - 'scripts/ukamf/ukamf-entity.js', - 'scripts/translations/checkCoverage.js', - 'scripts/translations/checkSanitizeOptions.js', - 'scripts/translations/checkVariables.js', - 'scripts/translations/cleanupUnusedLocales.js', - 'scripts/translations/config.js', - 'scripts/translations/download.js', - 'scripts/translations/insertHTMLFragments.js', - 'scripts/translations/replaceLinkFragments.js', - 'scripts/translations/sanitize.js', - 'scripts/translations/transformLocales.js', - 'scripts/translations/translateLocales.js', - 'scripts/translations/upload.js', - 'scripts/translations/uploadNonEnglish.js', - 'scripts/translations/utils.js', -]) -// These files are not to be converted (e.g. they use CommonJS features that are not available in ES Modules) -const excluded = new Set([ - 'modules/server-ce-scripts/scripts/create-user.js', // must be CJS for backwards compatibility - 'test/acceptance/config/settings.test.saas.js', // must be CJS for @overleaf/settings module - 'test/acceptance/config/settings.test.server-pro.js', // must be CJS for @overleaf/settings module - 'app/src/infrastructure/PackageVersions.js', // required by webpack -]) - -function fileIsESM(file) { - const relativePath = file.replace(process.cwd() + '/', '') - return file.endsWith('.mjs') || converted.has(relativePath) -} - -function fileCanBeConvertedToESM(file) { - const relativePath = file.replace(process.cwd() + '/', '') - if (fileIsESM(relativePath)) { - return false - } - return !excluded.has(relativePath) -} - -const { - _: args, - files, - help, - json, -} = minimist(process.argv.slice(2), { - boolean: ['files', 'help', 'json'], - alias: { - files: 'f', - help: 'h', - json: 'j', - }, - default: { - files: false, - help: false, - json: false, - }, -}) -const paths = args.length > 0 ? args : APP_CODE_PATH - -function usage() { - console.error(`Usage: node check-esm-migration.js [OPTS...] dir1 dir2 - node check-esm-migration.js file - -Usage with directories ----------------------- - -When the arguments are a list of directories it prints the status of ES Modules migration within those directories. -When no directory is provided, it checks app/ and modules/, which represent the entire codebase. - -With the --files (-f) option, it prints the list of JS files that: - - Are not migrated to ESM - - Are not required by any file that is not migrated yet to ESM (in the entire codebase) - -These files should be the most immediate candidates to be migrated. - -WARNING: please note that this script only looks up literals in require() statements, so paths -built dynamically (such as those in infrastructure/Modules.js) are not being taken into account. - -Usage with a JS file --------------------- - -When the argument is a JS file, the script outputs the files that depend on this file that have not been converted -yet to ES Modules. - -The files in the list must to be converted to ES Modules before converting the JS file. - -Example: - node scrips/check-esm-migration.js --files modules/admin-panel - node scrips/check-esm-migration.js app/src/router.js - -Options: - --files Prints the files that are not imported by app code via CommonJS - --json Prints the result in JSON format, including the list of files from --files - --help Prints this help -`) -} - -function resolveImportPaths(dir, file) { - const absolutePath = path.resolve(dir, file) - if (fs.existsSync(absolutePath)) { - return absolutePath - } else if (fs.existsSync(absolutePath + '.js')) { - return absolutePath + '.js' - } else if (fs.existsSync(absolutePath + '.mjs')) { - return absolutePath + '.mjs' - } else { - return null - } -} - -function collectJsFiles(dir, files = []) { - const items = fs.readdirSync(dir) - items.forEach(item => { - const fullPath = path.join(dir, item) - const stat = fs.statSync(fullPath) - - if (stat.isDirectory()) { - const basename = path.basename(fullPath) - - // skipping directories from search - if (!['frontend', 'node_modules'].includes(basename)) { - collectJsFiles(fullPath, files) - } - } else if ( - stat.isFile() && - (fullPath.endsWith('.js') || fullPath.endsWith('.mjs')) - ) { - files.push(fullPath) - } - }) - return files -} - -function extractImports(filePath) { - const fileContent = fs.readFileSync(filePath, 'utf-8') - - // not 100% compliant (string escaping, etc.) but does the work here - const contentWithoutComments = fileContent.replace( - /\/\/.*|\/\*[\s\S]*?\*\//g, - '' - ) - - const requireRegex = /require\s*\(\s*['"](.+?)['"]\s*\)/g - - const dependencies = [] - while (true) { - const match = requireRegex.exec(contentWithoutComments) - if (!match) { - break - } - dependencies.push(match[1]) - } - - // build absolute path for the imported file - return dependencies - .map(depPath => resolveImportPaths(path.dirname(filePath), depPath)) - .filter(path => path !== null) -} - -// Main function to process a list of directories and create the Map of dependencies -function findJSAndImports(directories) { - const fileDependenciesMap = new Map() - - directories.forEach(dir => { - if (fs.existsSync(dir)) { - const jsFiles = collectJsFiles(dir) - - jsFiles.forEach(filePath => { - const imports = extractImports(filePath) - fileDependenciesMap.set(filePath, imports) - }) - } else { - console.error(`Directory not found: ${dir}`) - process.exit(1) - } - }) - - return fileDependenciesMap -} - -function printDirectoriesReport(allFilesAndImports) { - // collect all files that are imported via CommonJS in the entire backend codebase - const filesImportedViaCjs = new Set() - allFilesAndImports.forEach((imports, file) => { - if (!fileIsESM(file)) { - imports.forEach(imprt => filesImportedViaCjs.add(imprt)) - } - }) - - // collect js files from the selected paths - const selectedFiles = Array.from( - findJSAndImports(paths.map(dir => path.resolve(dir))).keys() - ).filter(file => !file.endsWith('settings.test.js')) - const nonMigratedFiles = selectedFiles.filter(fileCanBeConvertedToESM) - const migratedFileCount = selectedFiles.filter(fileIsESM).length - - // collect files in the selected paths that are not imported via CommonJs in the entire backend codebase - const filesNotImportedViaCjs = nonMigratedFiles.filter( - file => !filesImportedViaCjs.has(file) - ) - - if (json) { - console.log( - JSON.stringify( - { - fileCount: selectedFiles.length, - migratedFileCount, - filesNotImportedViaCjs, - }, - null, - 2 - ) - ) - } else { - console.log(`Found ${selectedFiles.length} files in ${paths}: - - ${migratedFileCount} have been migrated to ES Modules (progress=${((migratedFileCount / selectedFiles.length) * 100).toFixed(2)}%) - - ${filesNotImportedViaCjs.length} are ready to migrate (these are not imported via CommonJS in the entire codebase) -`) - if (files) { - console.log(`Files that are ready to migrate:`) - filesNotImportedViaCjs.forEach(file => - console.log(` - ${file.replace(process.cwd() + '/', '')}`) - ) - } - } -} - -function printFileReport(allFilesAndImports) { - const filePath = path.resolve(paths[0]) - if (fileIsESM(filePath)) { - console.log(`${filePath} is already migrated to ESM`) - return - } - const filePathWithoutExtension = filePath.replace('.js', '') - - const importingFiles = [] - allFilesAndImports.forEach((imports, file) => { - if (fileIsESM(file)) { - return - } - if ( - imports.some( - imprt => imprt === filePath || imprt === filePathWithoutExtension - ) - ) { - importingFiles.push(file) - } - }) - - if (json) { - console.log( - JSON.stringify( - { - importingFiles, - }, - null, - 2 - ) - ) - } else { - console.log(`${filePath} is required by ${importingFiles.length} CJS file`) - importingFiles.forEach(file => - console.log(` - ${file.replace(process.cwd() + '/', '')}`) - ) - } -} - -function main() { - if (help) { - usage() - process.exit(0) - } - - // collect all the js files in the entire backend codebase (app/ + modules/) with its imports - const allFilesAndImports = findJSAndImports( - APP_CODE_PATH.map(dir => path.resolve(dir)) - ) - const entryPoint = fs.existsSync('app.js') ? 'app.js' : 'app.mjs' - allFilesAndImports.set(path.resolve(entryPoint), extractImports(entryPoint)) - - const isFileReport = fs.statSync(paths[0]).isFile() - - if (isFileReport) { - printFileReport(allFilesAndImports) - } else { - printDirectoriesReport(allFilesAndImports) - } -} - -main() diff --git a/services/web/scripts/export_active_subscription_users_csv.mjs b/services/web/scripts/export_active_subscription_users_csv.mjs deleted file mode 100644 index 96f777cb5e..0000000000 --- a/services/web/scripts/export_active_subscription_users_csv.mjs +++ /dev/null @@ -1,796 +0,0 @@ -import fs from 'node:fs' -import { Parser as CSVParser } from 'json2csv' -import minimist from 'minimist' -import pLimit from 'p-limit' -import Settings from '@overleaf/settings' -import { scriptRunner } from './lib/ScriptRunner.mjs' -import { - db, - ObjectId, - READ_PREFERENCE_SECONDARY, -} from '../app/src/infrastructure/mongodb.mjs' -import PaymentService from '../modules/subscriptions/app/src/PaymentService.mjs' -import FeaturesHelper from '../app/src/Features/Subscription/FeaturesHelper.mjs' -import CustomerIoPlanHelpers from '../app/src/Features/Subscription/CustomerIoPlanHelpers.mjs' -import { isStandaloneAiAddOnPlanCode } from '../app/src/Features/Subscription/AiHelper.mjs' -import InstitutionsGetter from '../app/src/Features/Institutions/InstitutionsGetter.mjs' - -const CSV_FIELDS = [ - { label: 'user_id', value: 'userId' }, - { label: 'email', value: 'email' }, - { label: 'plan_type', value: 'planType' }, - { label: 'display_plan_type', value: 'displayPlanType' }, - { - label: 'pre_migration_plan_type', - value: 'preMigrationPlanType', - }, - { - label: 'pre_migration_display_plan_type', - value: 'preMigrationDisplayPlanType', - }, - { label: 'plan_term', value: 'planTerm' }, - { label: 'ai_plan', value: 'aiPlan' }, - { label: 'ai_plan_term', value: 'aiPlanTerm' }, - { label: 'next_renewal_date', value: 'nextRenewalDate' }, - { label: 'expiry_date', value: 'expiryDate' }, - { label: 'group_ai_enabled', value: 'groupAIEnabled' }, - { label: 'group_role', value: 'groupRole' }, -] -const CSV_FIELD_NAMES = CSV_FIELDS.map(field => field.value) - -const ACTIVE_SUBSCRIPTION_STATES = ['active', 'trialing'] -const COMMONS_PLAN = getPlan(Settings.institutionPlanCode) - -function usage() { - console.log(` -Usage: - node scripts/export_active_subscription_users_csv.mjs [options] - -Options: - --outputPath Output CSV path (default: /tmp/active_subscription_users.csv) - --concurrency Concurrent payment-provider lookups (default: 5) - --batchSize Number of users processed per batch (default: 500) - --resumeAfterUserId Resume processing strictly after this user id - --checkpointInterval Log resumable checkpoint every N processed users (default: 10000) - --append Append to existing output file (for resume runs) - --help Show this message -`) -} - -function parseArgs() { - const args = minimist(process.argv.slice(2), { - string: ['outputPath', 'resumeAfterUserId'], - boolean: ['help', 'append'], - default: { - outputPath: '/tmp/active_subscription_users.csv', - concurrency: 5, - batchSize: 500, - checkpointInterval: 10000, - append: false, - help: false, - }, - }) - - if (args.help) { - usage() - process.exit(0) - } - - return args -} - -function getPlan(planCode) { - return Settings.plans.find(plan => plan.planCode === planCode) || null -} - -function getPlanType(subscription) { - if (isStandaloneAiAddOnPlanCode(subscription.planCode)) { - return 'standalone-ai-add-on' - } - return subscription.groupPlan ? 'group' : 'individual' -} - -function getPlanCadence(subscription, plan) { - if (plan != null) { - return plan.annual ? 'annual' : 'monthly' - } - - if (isStandaloneAiAddOnPlanCode(subscription.planCode)) { - return subscription.planCode.includes('annual') ? 'annual' : 'monthly' - } - - return '' -} - -function userHasPremiumAiFeatures(user) { - return ( - user?.features?.aiErrorAssistant === true || - user?.features?.aiUsageQuota === Settings.aiFeatures.unlimitedQuota - ) -} - -async function userHasCurrentInstitutionLicence(userId, commonsCache) { - if (commonsCache.has(userId)) { - return commonsCache.get(userId) - } - - try { - const institutions = - await InstitutionsGetter.promises.getCurrentInstitutionsWithLicence( - userId - ) - const hasCommons = Boolean(institutions?.length) - commonsCache.set(userId, hasCommons) - return hasCommons - } catch (error) { - console.warn( - `Failed to evaluate commons licence for user ${userId}: ${error.message}` - ) - commonsCache.set(userId, false) - return false - } -} - -function isMemberOfGroupSubscription(candidates) { - return candidates.some(candidate => candidate.subscription?.groupPlan) -} - -function getAiPlanForUser({ - bestSubscription, - planType, - individualSubscription, - paymentRecord, - user, - userIsMemberOfGroupSubscription, - userHasActiveOverleafSubscription, -}) { - const baseAiPlan = CustomerIoPlanHelpers.getAiPlanType( - bestSubscription, - individualSubscription, - paymentRecord, - user?.writefull, - userIsMemberOfGroupSubscription - ) - - if (baseAiPlan !== 'none') { - return baseAiPlan - } - - if (!userHasActiveOverleafSubscription && userHasPremiumAiFeatures(user)) { - return 'ai-assist' - } - - return 'none' -} - -function getPaymentState(subscription) { - if (subscription?.recurlyStatus?.state) { - return subscription.recurlyStatus.state - } - - if (subscription?.paymentProvider?.state) { - return subscription.paymentProvider.state - } - - return null -} - -function isActivePaidSubscription(subscription) { - const hasRecurlySubscription = Boolean(subscription?.recurlySubscription_id) - const hasStripeSubscription = Boolean( - subscription?.paymentProvider?.subscriptionId - ) - - if (!hasRecurlySubscription && !hasStripeSubscription) { - return false - } - - return ACTIVE_SUBSCRIPTION_STATES.includes(getPaymentState(subscription)) -} - -function getGroupAiEnabled(candidates) { - const groupCandidates = candidates.filter( - candidate => getPlanType(candidate.subscription) === 'group' - ) - - if (groupCandidates.length === 0) { - return '' - } - - return groupCandidates.some(candidate => - CustomerIoPlanHelpers.hasPlanAiEnabled(candidate.plan) - ) -} - -function getGroupRole(candidates, userId) { - const groupCandidates = candidates.filter( - candidate => candidate.subscription?.groupPlan - ) - - if (groupCandidates.length === 0) { - return '' - } - - const isGroupAdmin = groupCandidates.some( - candidate => candidate.subscription?.admin_id?.toString() === userId - ) - if (isGroupAdmin) { - return 'admin' - } - - const isGroupManager = groupCandidates.some(candidate => { - const managerIds = (candidate.subscription?.manager_ids || []).map(id => - id?.toString() - ) - return managerIds.includes(userId) - }) - if (isGroupManager) { - return 'manager' - } - - return 'member' -} - -function chooseBestCandidate(candidates) { - let best = null - - for (const candidate of candidates) { - if (best == null) { - best = candidate - continue - } - - const candidateType = getPlanType(candidate.subscription) - const bestType = getPlanType(best.subscription) - - if (candidateType === 'standalone-ai-add-on' && bestType !== 'free') { - continue - } - - if ( - candidateType !== 'standalone-ai-add-on' && - bestType === 'standalone-ai-add-on' - ) { - best = candidate - continue - } - - if ( - FeaturesHelper.isFeatureSetBetter( - candidate.plan?.features || {}, - best.plan?.features || {} - ) - ) { - best = candidate - } - } - - return best -} - -function getSubscriptionQuery() { - return { - $or: [ - { - recurlySubscription_id: { $exists: true, $nin: ['', null] }, - 'recurlyStatus.state': { $in: ACTIVE_SUBSCRIPTION_STATES }, - }, - { - 'paymentProvider.subscriptionId': { $exists: true, $nin: ['', null] }, - 'paymentProvider.state': { $in: ACTIVE_SUBSCRIPTION_STATES }, - }, - ], - } -} - -function getSubscriptionProjection() { - return { - _id: 1, - admin_id: 1, - manager_ids: 1, - member_ids: 1, - planCode: 1, - groupPlan: 1, - recurlySubscription_id: 1, - recurlyStatus: 1, - paymentProvider: 1, - addOns: 1, - } -} - -function getSupplementaryUserQuery() { - return { - $or: [ - { 'writefull.isPremium': true }, - { 'features.aiErrorAssistant': true }, - { 'features.aiUsageQuota': Settings.aiFeatures.unlimitedQuota }, - { - emails: { - $elemMatch: { - confirmedAt: { $exists: true }, - 'affiliation.institution.confirmed': true, - 'affiliation.licence': { $exists: true, $ne: 'free' }, - 'affiliation.pastReconfirmDate': { $ne: true }, - }, - }, - }, - ], - } -} - -function getTargetUserIdsPipeline(resumeAfterUserId) { - const pipeline = [ - { $match: getSubscriptionQuery() }, - { - $project: { - participantIds: { - $setUnion: [ - [{ $ifNull: ['$admin_id', null] }], - { $ifNull: ['$manager_ids', []] }, - { $ifNull: ['$member_ids', []] }, - ], - }, - }, - }, - { $unwind: '$participantIds' }, - { $match: { participantIds: { $ne: null } } }, - { $group: { _id: '$participantIds' } }, - { - $unionWith: { - coll: 'users', - pipeline: [ - { $match: getSupplementaryUserQuery() }, - { $project: { _id: 1 } }, - ], - }, - }, - { $group: { _id: '$_id' } }, - ] - - if (resumeAfterUserId) { - let resumeId = resumeAfterUserId - try { - resumeId = new ObjectId(resumeAfterUserId) - } catch { - // leave as-is for non-ObjectId identifiers - } - pipeline.push({ $match: { _id: { $gt: resumeId } } }) - } - - pipeline.push({ $sort: { _id: 1 } }) - - return pipeline -} - -function getTargetUserIdsCursor(resumeAfterUserId) { - return db.subscriptions.aggregate( - getTargetUserIdsPipeline(resumeAfterUserId), - { - allowDiskUse: true, - readPreference: READ_PREFERENCE_SECONDARY, - } - ) -} - -function isInvalidOrMissingSubscriptionError(error) { - const message = String(error?.message || '').toLowerCase() - - return ( - message.includes('no such subscription') || - message.includes('invalid subscription') || - message.includes('subscription not found') - ) -} - -async function getSubscriptionPaymentInfo( - userId, - subscription, - paymentInfoCache -) { - const subscriptionId = subscription._id.toString() - if (paymentInfoCache.has(subscriptionId)) { - return { - ...paymentInfoCache.get(subscriptionId), - skip: false, - } - } - - try { - const paymentRecord = - await PaymentService.promises.getPaymentFromRecord(subscription) - const nextRenewalDate = - CustomerIoPlanHelpers.getNextRenewalDateFromPaymentRecord( - paymentRecord - ) || '' - const paymentInfo = { nextRenewalDate, paymentRecord } - paymentInfoCache.set(subscriptionId, paymentInfo) - return { ...paymentInfo, skip: false } - } catch (error) { - if (isInvalidOrMissingSubscriptionError(error)) { - console.warn( - `Skipping user ${userId}: invalid/missing payment-provider subscription for subscription ${subscriptionId} (${error.message})` - ) - return { nextRenewalDate: '', paymentRecord: null, skip: true } - } - - console.error( - `Failed to get renewal date for subscription ${subscriptionId}:`, - error.message - ) - const paymentInfo = { nextRenewalDate: '', paymentRecord: null } - paymentInfoCache.set(subscriptionId, paymentInfo) - return { ...paymentInfo, skip: false } - } -} - -async function getUsersByIds(userIds) { - const objectIds = [] - for (const userId of userIds) { - try { - objectIds.push(new ObjectId(userId)) - } catch { - // ignore invalid ObjectId strings - } - } - - const idFilters = [] - if (objectIds.length > 0) { - idFilters.push({ _id: { $in: objectIds } }) - } - if (userIds.length > 0) { - idFilters.push({ _id: { $in: userIds } }) - } - - if (idFilters.length === 0) { - return new Map() - } - - const users = await db.users - .find(idFilters.length === 1 ? idFilters[0] : { $or: idFilters }, { - projection: { - _id: 1, - email: 1, - features: 1, - writefull: 1, - }, - readPreference: READ_PREFERENCE_SECONDARY, - }) - .toArray() - - return new Map(users.map(user => [user._id.toString(), user])) -} - -async function getActiveSubscriptionsForUserIds(userIds) { - const objectIds = [] - for (const userId of userIds) { - try { - objectIds.push(new ObjectId(userId)) - } catch { - // ignore invalid ObjectId strings - } - } - - const idValues = objectIds.length > 0 ? objectIds : userIds - - return await db.subscriptions - .find( - { - ...getSubscriptionQuery(), - $or: [ - { admin_id: { $in: idValues } }, - { manager_ids: { $in: idValues } }, - { member_ids: { $in: idValues } }, - ], - }, - { - projection: getSubscriptionProjection(), - readPreference: READ_PREFERENCE_SECONDARY, - } - ) - .toArray() -} - -function buildUserCandidatesMap(subscriptions, allowedUserIds) { - const userCandidates = new Map() - const allowedUserIdsSet = new Set(allowedUserIds) - - function addCandidate(userId, candidate) { - if (!userId || !allowedUserIdsSet.has(userId)) { - return - } - if (!userCandidates.has(userId)) { - userCandidates.set(userId, []) - } - userCandidates.get(userId).push(candidate) - } - - for (const subscription of subscriptions) { - const plan = getPlan(subscription.planCode) - const baseCandidate = { subscription, plan } - - const adminId = subscription.admin_id?.toString() - addCandidate(adminId, baseCandidate) - - if (Array.isArray(subscription.manager_ids)) { - for (const managerIdRaw of subscription.manager_ids) { - addCandidate(managerIdRaw?.toString(), baseCandidate) - } - } - - if (subscription.groupPlan && Array.isArray(subscription.member_ids)) { - for (const memberIdRaw of subscription.member_ids) { - addCandidate(memberIdRaw?.toString(), baseCandidate) - } - } - } - - return userCandidates -} - -function writeCsvRows(writeStream, rows, includeHeader) { - if (rows.length === 0) { - return - } - - const csvParser = new CSVParser({ - fields: includeHeader ? CSV_FIELDS : CSV_FIELD_NAMES, - header: includeHeader, - eol: '\n', - }) - - writeStream.write(`${csvParser.parse(rows)}\n`) -} - -function writeCsvHeader(writeStream) { - writeStream.write(`${CSV_FIELDS.map(field => field.label).join(',')}\n`) -} - -async function processUserBatch({ - userIds, - writeStream, - paymentInfoCache, - commonsCache, - limit, - trackProgress, - totalUsersCount, - globalState, - checkpointInterval, -}) { - const usersById = await getUsersByIds(userIds) - const subscriptions = await getActiveSubscriptionsForUserIds(userIds) - const activeSubscriptions = subscriptions.filter(isActivePaidSubscription) - const userCandidates = buildUserCandidatesMap(activeSubscriptions, userIds) - - const rows = await Promise.all( - userIds.map(userId => - limit(async () => { - const user = usersById.get(userId) - if (!user?.email) { - return null - } - - const candidates = userCandidates.get(userId) || [] - const bestCandidate = chooseBestCandidate(candidates) - - const hasCommons = await userHasCurrentInstitutionLicence( - userId, - commonsCache - ) - const commonsBeatsBestSubscription = - CustomerIoPlanHelpers.shouldUseCommonsBestSubscription( - hasCommons, - bestCandidate, - COMMONS_PLAN - ) - - const resolvedSubscription = commonsBeatsBestSubscription - ? null - : bestCandidate?.subscription - const resolvedPlan = commonsBeatsBestSubscription - ? COMMONS_PLAN - : bestCandidate?.plan - - const bestSubscriptionForPlanType = commonsBeatsBestSubscription - ? { type: 'commons', plan: COMMONS_PLAN } - : resolvedSubscription - ? { - type: getPlanType(resolvedSubscription), - plan: resolvedPlan, - } - : { type: 'free' } - const planType = CustomerIoPlanHelpers.normalizePlanType( - bestSubscriptionForPlanType - ) - const displayPlanType = - CustomerIoPlanHelpers.getFriendlyPlanName(planType) || '' - const planTerm = resolvedSubscription - ? getPlanCadence(resolvedSubscription, resolvedPlan) - : '' - - const userIsMemberOfGroupSubscription = - isMemberOfGroupSubscription(candidates) - const userHasActiveOverleafSubscription = bestCandidate != null - let nextRenewalDate = '' - let expiryDate = '' - let paymentRecord = null - if (resolvedSubscription) { - const paymentInfo = await getSubscriptionPaymentInfo( - userId, - resolvedSubscription, - paymentInfoCache - ) - if (paymentInfo.skip) { - return null - } - nextRenewalDate = paymentInfo.nextRenewalDate - paymentRecord = paymentInfo.paymentRecord - expiryDate = - CustomerIoPlanHelpers.getExpiryDateFromPaymentRecord( - paymentRecord - ) || '' - } - - const aiPlan = getAiPlanForUser({ - bestSubscription: bestSubscriptionForPlanType, - planType, - individualSubscription: resolvedSubscription, - paymentRecord, - user, - userIsMemberOfGroupSubscription, - userHasActiveOverleafSubscription, - }) - const aiPlanTerm = CustomerIoPlanHelpers.getAiPlanCadence( - aiPlan, - bestSubscriptionForPlanType, - resolvedSubscription, - paymentRecord - ) - const groupAIEnabled = getGroupAiEnabled(candidates) - const groupRole = getGroupRole(candidates, userId) - - return { - userId, - email: user.email, - planType, - displayPlanType, - preMigrationPlanType: planType, - preMigrationDisplayPlanType: displayPlanType, - planTerm, - aiPlan, - aiPlanTerm: aiPlanTerm || '', - nextRenewalDate, - expiryDate, - groupAIEnabled, - groupRole, - } - }) - ) - ) - - const csvRows = [] - - for (const row of rows) { - globalState.processedCount += 1 - if (row) { - csvRows.push(row) - globalState.writtenCount += 1 - } else { - globalState.skippedCount += 1 - } - } - - writeCsvRows(writeStream, csvRows, globalState.shouldWriteHeader) - if (csvRows.length > 0) { - globalState.shouldWriteHeader = false - } - - globalState.lastProcessedUserId = userIds[userIds.length - 1] - - if (globalState.processedCount % checkpointInterval < userIds.length) { - await trackProgress( - `Checkpoint: processed=${globalState.processedCount}/${totalUsersCount || '?'} written=${globalState.writtenCount} skipped=${globalState.skippedCount} resumeAfterUserId=${globalState.lastProcessedUserId}` - ) - } -} - -async function main(trackProgress) { - const { - outputPath, - concurrency, - batchSize, - resumeAfterUserId, - checkpointInterval, - append, - } = parseArgs() - - await trackProgress( - 'Building target user cursor (subscriptions + supplementary users)' - ) - if (resumeAfterUserId) { - await trackProgress( - `Resume enabled: starting after user id ${resumeAfterUserId}` - ) - } - - const paymentInfoCache = new Map() - const commonsCache = new Map() - const limit = pLimit(Number(concurrency) || 5) - const resolvedBatchSize = Math.max(1, Number(batchSize) || 500) - const resolvedCheckpointInterval = Math.max( - 100, - Number(checkpointInterval) || 10000 - ) - - const outputFileExists = fs.existsSync(outputPath) - const outputFileHasContent = - outputFileExists && fs.statSync(outputPath).size > 0 - const writeStream = fs.createWriteStream(outputPath, { - flags: append ? 'a' : 'w', - }) - - const globalState = { - processedCount: 0, - writtenCount: 0, - skippedCount: 0, - lastProcessedUserId: resumeAfterUserId || null, - shouldWriteHeader: !append || !outputFileHasContent, - } - - let pendingUserIds = [] - const targetUserIdsCursor = getTargetUserIdsCursor(resumeAfterUserId) - - for await (const doc of targetUserIdsCursor) { - pendingUserIds.push(doc._id.toString()) - - if (pendingUserIds.length >= resolvedBatchSize) { - await processUserBatch({ - userIds: pendingUserIds, - writeStream, - paymentInfoCache, - commonsCache, - limit, - trackProgress, - totalUsersCount: null, - globalState, - checkpointInterval: resolvedCheckpointInterval, - }) - pendingUserIds = [] - } - } - - if (pendingUserIds.length > 0) { - await processUserBatch({ - userIds: pendingUserIds, - writeStream, - paymentInfoCache, - commonsCache, - limit, - trackProgress, - totalUsersCount: null, - globalState, - checkpointInterval: resolvedCheckpointInterval, - }) - } - - if (globalState.shouldWriteHeader) { - writeCsvHeader(writeStream) - globalState.shouldWriteHeader = false - } - - writeStream.end() - - await trackProgress( - `Final checkpoint: processed=${globalState.processedCount} written=${globalState.writtenCount} skipped=${globalState.skippedCount} resumeAfterUserId=${globalState.lastProcessedUserId}` - ) - await trackProgress(`CSV generated: ${outputPath}`) - console.log(`✅ Export complete: ${outputPath}`) - console.log(`Rows written: ${globalState.writtenCount}`) -} - -try { - await scriptRunner(main) - process.exit(0) -} catch (error) { - console.error(error) - process.exit(1) -} diff --git a/services/web/scripts/extract_day1_churn_users.mjs b/services/web/scripts/extract_day1_churn_users.mjs deleted file mode 100644 index d95cdf3959..0000000000 --- a/services/web/scripts/extract_day1_churn_users.mjs +++ /dev/null @@ -1,275 +0,0 @@ -import { scriptRunner } from './lib/ScriptRunner.mjs' -import * as csv from 'csv' -import fs from 'node:fs' -import minimist from 'minimist' -import { User } from '../app/src/models/User.mjs' - -/** - * This script extracts users who churned after day 1 - ie. their last session was within 24 hours of registering - * - * It will: - * — Find users whose lastActive is within 24 hours of their signUpDate - * — Filter for a configurable lookback period (default: 6 months) - * — Export user IDs and email addresses to CSV - * - * Usage: - * - Locally: - * - docker compose exec web bash - * - node scripts/extract_day1_churn_users.js - * - On the server: - * - rake connect:app[staging,web] - * - node scripts/extract_day1_churn_users.js - * - exit - * - kubectl cp web-standalone-prod-XXXXX:/tmp/day1_churn_users.csv ~/day1_churn_users.csv - */ - -function usage() { - console.log( - ` - Day 1 Churn Users extraction, outputs to /tmp/day1_churn_users.csv - - Usage: - node scripts/extract_day1_churn_users.js [--lookbackMonths=] [--outputPath=] [--sampleSize=] [--excludeRecentDays=] [--includeLastActive] [--includeHoursActive] - - Options: - --help Show this screen - - --lookbackMonths= Number of months to look back for registrations (default: 6) - - --outputPath= Output file path (default: /tmp/day1_churn_users.csv) - - --sampleSize= Maximum number of users to randomly sample per month (default: all users) - - --excludeRecentDays= Exclude users who registered in the last X days to avoid premature churn classification (default: 7) - - --includeLastActive Include lastActive column in the output CSV (default: false) - - --includeHoursActive Include hoursActive column in the output CSV (default: false) - - Description: - This script identifies users who churned after day 1, meaning their last activity - was within 24 hours of their registration date. It looks for users who: - 1. Registered within the specified lookback period - 2. Have a lastActive timestamp - 3. Their lastActive is <= 24 hours after their signUpDate - 4. Did not register within the recent exclusion period - - Grace Period: - The --excludeRecentDays parameter prevents prematurely marking users as churned. - For example, with --excludeRecentDays=7, users who registered in the last 7 days - will be excluded from the analysis. - - Sampling: - When --sampleSize is specified, the script will add a MongoDB $sample stage to - randomly sample up to that number of users from each month within the lookback - period. For example, with --sampleSize=100 and --lookbackMonths=6, you'll get - up to 100 randomly selected users for each of the 6 months, for a maximum of - 600 users total. - ` - ) -} - -function parseArgs() { - const argv = minimist(process.argv.slice(2), { - string: ['outputPath'], - number: ['lookbackMonths', 'sampleSize', 'excludeRecentDays'], - bool: ['help', 'includeLastActive', 'includeHoursActive'], - default: { - help: false, - lookbackMonths: 6, - outputPath: '/tmp/day1_churn_users.csv', - sampleSize: null, // null => return all users - excludeRecentDays: 7, // Exclude users who registered in the last 7 days - includeLastActive: false, - includeHoursActive: false, - }, - }) - - if (argv.help) { - usage() - process.exit(0) - } - - return argv -} - -async function getDay1ChurnUsers({ - lookbackMonths, - sampleSize, - excludeRecentDays, -}) { - // Calculate the actual lookback date used in queries (first day of the oldest month) - const lookbackDate = new Date() - lookbackDate.setMonth(lookbackDate.getMonth() - lookbackMonths) - lookbackDate.setDate(1) - lookbackDate.setHours(0, 0, 0, 0) - - const exclusionDate = new Date() - exclusionDate.setDate(exclusionDate.getDate() - excludeRecentDays) - - console.log( - `Looking for users who registered after: ${lookbackDate.toISOString()}` - ) - console.log( - `Excluding users who registered after: ${exclusionDate.toISOString()} (last ${excludeRecentDays} days)` - ) - - const allChurnUsers = [] - - for (let monthOffset = 0; monthOffset < lookbackMonths; monthOffset++) { - const monthStart = new Date() - monthStart.setMonth(monthStart.getMonth() - monthOffset - 1) - monthStart.setDate(1) - monthStart.setHours(0, 0, 0, 0) - - const monthEnd = new Date(monthStart) - monthEnd.setMonth(monthEnd.getMonth() + 1) - - // Skip months that would include users in the exclusion period - if (monthEnd > exclusionDate) { - // Adjust monthEnd to the exclusion date if the month overlaps - if (monthStart < exclusionDate) { - monthEnd.setTime(exclusionDate.getTime()) - } else { - continue - } - } - - const monthKey = `${monthStart.getFullYear()}-${String(monthStart.getMonth() + 1).padStart(2, '0')}` - - console.log( - `Processing month ${monthKey} (${monthStart.toISOString()} to ${monthEnd.toISOString()})` - ) - - const pipeline = [ - // Match users who registered in this month and have a lastActive property - { - $match: { - signUpDate: { - $gte: monthStart, - $lt: monthEnd, - }, - lastActive: { $exists: true, $ne: null }, - }, - }, - // Compute the time between registration and last active - { - $addFields: { - timeDiffHours: { - $divide: [ - { $subtract: ['$lastActive', '$signUpDate'] }, - 1000 * 60 * 60, // Convert milliseconds to hours - ], - }, - }, - }, - // Filter for day 1 churn (0-24 hours) - { - $match: { - timeDiffHours: { $gte: 0, $lte: 24 }, - }, - }, - { - $project: { - _id: 1, - email: 1, - signUpDate: 1, - lastActive: 1, - timeDiffHours: 1, - }, - }, - ] - - // Add sampling stage if specified - if (sampleSize && sampleSize > 0) { - pipeline.push({ $sample: { size: sampleSize } }) - } - - const monthUsers = await User.aggregate(pipeline).exec() - - console.log( - `Month ${monthKey}: Found ${monthUsers.length} day 1 churn users` - ) - - const formattedUsers = monthUsers.map(user => ({ - userId: user._id.toString(), - email: user.email, - signUpDate: new Date(user.signUpDate).toISOString(), - lastActive: new Date(user.lastActive).toISOString(), - hoursActive: user.timeDiffHours.toFixed(2), - })) - - allChurnUsers.push(...formattedUsers) - } - - console.log(`Total users collected: ${allChurnUsers.length}`) - return allChurnUsers -} - -const args = parseArgs() - -async function runScript() { - console.log( - `Starting Day 1 churn extraction with lookback period: ${args.lookbackMonths} months` - ) - console.log( - `Excluding users who registered in the last ${args.excludeRecentDays} days` - ) - if (args.sampleSize) { - console.log(`Sampling enabled: maximum ${args.sampleSize} users per month`) - } else { - console.log('No sampling - returning all users') - } - - if (args.includeLastActive) { - console.log('Including lastActive column in output') - } - - if (args.includeHoursActive) { - console.log('Including hoursActive column in output') - } - - const churnUsers = await getDay1ChurnUsers(args) - - if (churnUsers.length === 0) { - console.log('No day 1 churn users found for the specified period') - process.exit(0) - } - - console.log(`Writing ${churnUsers.length} users to ${args.outputPath}...`) - - const columns = ['userId', 'email', 'signUpDate'] - - if (args.includeLastActive) { - columns.push('lastActive') - } - - if (args.includeHoursActive) { - columns.push('hoursActive') - } - - csv.stringify( - churnUsers, - { - header: true, - columns, - }, - function (err, output) { - if (err) { - console.error('Error writing CSV output:', err) - process.exit(1) - } - - fs.writeFileSync(args.outputPath, output) - console.log( - `Successfully wrote ${churnUsers.length} day 1 churn users to ${args.outputPath}` - ) - process.exit(0) - } - ) -} - -scriptRunner(runScript, args).catch(err => { - console.error('Script failed:', err) - process.exit(1) -}) diff --git a/services/web/scripts/extract_onboardingdatacollection_csv.mjs b/services/web/scripts/extract_onboardingdatacollection_csv.mjs deleted file mode 100644 index 2eac33c3f7..0000000000 --- a/services/web/scripts/extract_onboardingdatacollection_csv.mjs +++ /dev/null @@ -1,95 +0,0 @@ -import { scriptRunner } from './lib/ScriptRunner.mjs' -import * as csv from 'csv' -import fs from 'node:fs' -import { OnboardingDataCollection } from '../app/src/models/OnboardingDataCollection.mjs' - -/** - * This script extracts the OnboardingDataCollection collection from the database - * and writes it to a CSV file. - * - * Usage: - * - Locally: - * - docker compose exec web bash - * - node services/web/scripts/extract_onboardingdatacollection_csv.js - * - On the server: - * - rake connect:app[staging,web] - * - node web/scripts/extract_onboardingdatacollection_csv.js - * - exit - * - kubectl cp web-standalone-prod-XXXXX:/tmp/onboardingDataCollection.csv ~/onboardingDataCollection.csv - * - */ - -const mapFields = doc => { - return { - primaryOccupation: doc.primaryOccupation, - usedLatex: doc.usedLatex, - companyDivisionDepartment: doc.companyDivisionDepartment, - companyJobTitle: doc.companyJobTitle, - governmentJobTitle: doc.governmentJobTitle, - institutionName: doc.institutionName, - otherJobTitle: doc.otherJobTitle, - nonprofitDivisionDepartment: doc.nonprofitDivisionDepartment, - nonprofitJobTitle: doc.nonprofitJobTitle, - role: doc.role, - subjectArea: doc.subjectArea, - updatedAt: new Date(doc.updatedAt).toISOString(), - userId: doc._id.toString(), // _id is set as the userId - firstName: Boolean(doc.firstName).toString(), - lastName: Boolean(doc.lastName).toString(), - } -} - -const runScript = async () => { - console.time('CSV Writing Duration') - - console.log('Starting to write to csv file...') - - const cursor = OnboardingDataCollection.find().cursor() - - const csvWriter = csv.stringify({ - header: true, - columns: [ - 'primaryOccupation', - 'usedLatex', - 'companyDivisionDepartment', - 'companyJobTitle', - 'governmentJobTitle', - 'institutionName', - 'otherJobTitle', - 'nonprofitDivisionDepartment', - 'nonprofitJobTitle', - 'role', - 'subjectArea', - 'updatedAt', - 'userId', - 'firstName', - 'lastName', - ], - }) - - const writeStream = fs.createWriteStream('/tmp/onboardingDataCollection.csv') - - csvWriter.pipe(writeStream) - - let lineCount = 0 - for (let doc = await cursor.next(); doc != null; doc = await cursor.next()) { - lineCount++ - csvWriter.write(mapFields(doc)) - } - - csvWriter.end() - - writeStream.on('finish', () => { - console.log(`Done writing to csv file. Total lines written: ${lineCount}`) - console.timeEnd('CSV Writing Duration') - process.exit() - }) - - writeStream.on('error', err => console.error('Write Stream Error:', err)) - csvWriter.on('error', err => console.error('CSV Writer Error:', err)) -} - -scriptRunner(runScript).catch(err => { - console.error(err) - process.exit(1) -}) diff --git a/services/web/scripts/extract_onboardingdatacollection_never_used_latex.mjs b/services/web/scripts/extract_onboardingdatacollection_never_used_latex.mjs deleted file mode 100644 index 57705c0aa5..0000000000 --- a/services/web/scripts/extract_onboardingdatacollection_never_used_latex.mjs +++ /dev/null @@ -1,200 +0,0 @@ -import { scriptRunner } from './lib/ScriptRunner.mjs' -import * as csv from 'csv' -import fs from 'node:fs' -import minimist from 'minimist' -import { OnboardingDataCollection } from '../app/src/models/OnboardingDataCollection.mjs' -import { User } from '../app/src/models/User.mjs' -import SubscriptionLocator from '../app/src/Features/Subscription/SubscriptionLocator.mjs' -import Settings from '@overleaf/settings' -import { fetchJson } from '@overleaf/fetch-utils' - -/** - * This script extracts ODC data with some extra fields, and filters on registration date and LaTeX experience - * - * It will: - * — filter for used_latex=never - * — augment rows with user registered date and email addresses - * — filter on users registered after a certain date - * — export updated CSV - * - * Usage: - * - Locally: - * - docker compose exec web bash - * - node scripts/extract_onboardingdatacollection_never_used_latex.js - * - On the server: - * - rake connect:app[staging,web] - * - node scripts/extract_onboardingdatacollection_never_used_latex.js - * - exit - * - kubectl cp web-standalone-prod-XXXXX:/tmp/odc_neverUsedLatex.csv ~/odc_neverUsedLatex.csv - */ - -function usage() { - console.log( - ` - Onboarding Data Collection extraction, outputs to /tmp/odc_neverUsedLatex.csv - - Usage: - node scripts/extract_onboardingdatacollection_never_used_latex.js [--registeredBefore=] [--studentsOnly] [--includeSignUpDate] [--includeCountry] [--includePlanCode] - - Options: - --help Show this screen - - --registeredBefore= Limit to users registered before ISO 8601 date (eg. 2024-08-01) - - --studentsOnly Only include users whose primary occupation is 'university' or 'school' - - --includeSignUpDate Include signUpDate column - - --includeCountry Include countryCode column (inferred from institution and possibly missing) - - --includePlanCode Include planCode column - ` - ) -} - -function parseArgs() { - const argv = minimist(process.argv.slice(2), { - string: ['registeredBefore'], - bool: [ - 'help', - 'studentsOnly', - 'includeSignUpDate', - 'includeCountry', - 'includePlanCode', - ], - default: { - help: false, - studentsOnly: false, - includeSignUpDate: false, - includeCountry: false, - includePlanCode: false, - registeredBefore: '2024-02-18', - }, - }) - - if (argv.help) { - usage() - process.exit(0) - } - return argv -} - -async function getEmails(userIds, { registeredBefore }) { - const userEmails = await User.find( - { _id: { $in: userIds }, signUpDate: { $lte: new Date(registeredBefore) } }, - { email: 1, signUpDate: 1 } - ).exec() - return userEmails.map(({ email, signUpDate }) => ({ - email, - signUpDate: new Date(signUpDate).toISOString(), - })) -} - -async function getUsers({ studentsOnly }) { - const odcCriteria = { usedLatex: 'never' } - if (studentsOnly) { - odcCriteria.primaryOccupation = 'university' - } - - const cursor = OnboardingDataCollection.find(odcCriteria).cursor() - - const userIds = [] - const institutionNames = [] - for (let doc = await cursor.next(); doc != null; doc = await cursor.next()) { - userIds.push(doc._id.toString()) - institutionNames.push(doc.institutionName) - } - - return { userIds, institutionNames } -} - -async function getUserPlanCodes(users) { - const planCodes = [] - for await (const user of users) { - const subscription = - await SubscriptionLocator.promises.getUsersSubscription(user) - planCodes.push(subscription?.planCode || 'free') - } - return planCodes -} - -// inferred from institution so will not always be available or accurate -async function getUserCountries(institutions) { - const countryCodes = [] - // cache any institutions we lookup to avoid making duplicate calls - const institutionLookups = {} - for await (const inst of institutions) { - if (!inst) { - countryCodes.push(undefined) - continue - } - if (institutionLookups[inst]) { - countryCodes.push(institutionLookups[inst]) - continue - } - try { - const url = `${Settings.apis.web.url}/institutions/search?search=${encodeURIComponent(inst)}&max_results=1` - const response = await fetchJson(url) - countryCodes.push(response[0]?.country_code) - institutionLookups[inst] = response[0]?.country_code - } catch (e) { - // if institution search fails just move on - console.log(`Error when looking up institution ${inst}: ${e.message}`) - countryCodes.push(undefined) - } - } - return countryCodes -} -const args = parseArgs() - -async function runScript() { - const columns = ['email'] - - if (args.includeSignUpDate) { - columns.push('signUpDate') - } - - const users = await getUsers(args) - let userEmails = await getEmails(users.userIds, args) - - if (args.includePlanCode) { - columns.push('planCode') - const planCodes = await getUserPlanCodes(users.userIds) - userEmails = userEmails.map((user, index) => { - user.planCode = planCodes[index] - return user - }) - } - - if (args.includeCountry) { - columns.push('country') - const countryCodes = await getUserCountries(users.institutionNames) - userEmails = userEmails.map((user, index) => { - user.country = countryCodes[index] - return user - }) - } - - console.log('Starting to write to csv file...') - - csv.stringify( - userEmails, - { - header: true, - columns, - }, - function (err, output) { - fs.writeFileSync('/tmp/odc_neverUsedLatex.csv', output) - if (err) { - console.log('error writing csv output: ', err) - process.exit(1) - } - process.exit() - } - ) -} - -scriptRunner(runScript, args).catch(err => { - console.error(err) - process.exit(1) -}) diff --git a/services/web/scripts/fetch-pyodide-packages.mjs b/services/web/scripts/fetch-pyodide-packages.mjs deleted file mode 100644 index dc10bc856f..0000000000 --- a/services/web/scripts/fetch-pyodide-packages.mjs +++ /dev/null @@ -1,136 +0,0 @@ -/* eslint-disable @overleaf/require-script-runner */ -// This script doesn't work with ScriptRunner because it is run during the build process. -import { createReadStream, createWriteStream } from 'node:fs' -import { mkdir, readdir, rm, stat, writeFile } from 'node:fs/promises' -import { Readable } from 'node:stream' -import { pipeline } from 'node:stream/promises' -import { execFile } from 'node:child_process' -import { createHash } from 'node:crypto' -import { promisify } from 'node:util' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -const execFileAsync = promisify(execFile) - -const SERVICE_WEB_DIR = path.resolve(fileURLToPath(import.meta.url), '../..') - -// Pinned pyodide release tarball. Keep PYODIDE_VERSION in sync with the -// "pyodide" entry in services/web/package.json. When bumping, update both -// PYODIDE_VERSION and EXPECTED_SHA256 together; fetch the hash via: -// curl -sL https://api.github.com/repos/pyodide/pyodide/releases/tags/ \ -// | jq -r '.assets[] | select(.name=="pyodide-.tar.bz2") | .digest' -// (strip the "sha256:" prefix). Cross-check by downloading the tarball and -// running `shasum -a 256 pyodide-.tar.bz2`. -const PYODIDE_VERSION = '0.29.3' -const EXPECTED_SHA256 = - '458e8ddbcbb6e21037d3237cd5c5146c451765bc738dfa2249ff34c5140331e4' -const TARGET_DIR = path.join( - SERVICE_WEB_DIR, - 'public/js/libs/pyodide', - PYODIDE_VERSION -) -const TARBALL_NAME = `pyodide-${PYODIDE_VERSION}.tar.bz2` -const RELEASE_URL = `https://github.com/pyodide/pyodide/releases/download/${PYODIDE_VERSION}/${TARBALL_NAME}` -const COMPLETE_MARKER = path.join(TARGET_DIR, '.fetch-complete') - -async function download(url, dest) { - console.log(`Downloading ${url}`) - const res = await fetch(url, { redirect: 'follow' }) - if (!res.ok) { - throw new Error(`Failed to fetch ${url}: ${res.status} ${res.statusText}`) - } - await pipeline(Readable.fromWeb(res.body), createWriteStream(dest)) -} - -async function sha256(file) { - const hash = createHash('sha256') - await pipeline(createReadStream(file), hash) - return hash.digest('hex') -} - -// The version subdir only needs what pyodide fetches via packageBaseUrl -// (wheels, their .metadata sidecars, and lib*.zip shared libraries). Skip -// everything else: -// - core runtime (pyodide.mjs / asm / stdlib / lock) lives one level up, -// copied from the npm package by webpack CopyPlugin. -// - *-tests.tar / test-*.zip: per-package test fixtures and pyodide's own -// test packages, not used at runtime. -// - console*.html, python / python.exe / python.bat / python_cli_entry.mjs, -// README.md: REPL UI, CLI shims, and docs. -const TAR_EXCLUDES = [ - 'pyodide.mjs', - 'pyodide.asm.js', - 'pyodide.asm.wasm', - 'python_stdlib.zip', - 'pyodide-lock.json', - '*-tests.tar', - 'test-*.zip', - 'console*.html', - 'python', - 'python.exe', - 'python.bat', - 'python_cli_entry.mjs', - 'README.md', -] - -async function extract(tarball, targetDir) { - console.log(`Extracting ${path.basename(tarball)}`) - // Tarball contains a top-level pyodide/ folder; strip it so contents land - // directly in targetDir. - await execFileAsync('tar', [ - '-xjf', - tarball, - '-C', - targetDir, - '--strip-components=1', - ...TAR_EXCLUDES.map(p => `--exclude=${p}`), - ]) -} - -async function main() { - try { - await stat(COMPLETE_MARKER) - console.log(`Pyodide ${PYODIDE_VERSION} already present at ${TARGET_DIR}`) - return - } catch (err) { - if (err.code !== 'ENOENT') throw err - } - - // A prior run may have left a partial install without the marker; wipe it - // so extraction starts from a clean directory. - await rm(TARGET_DIR, { recursive: true, force: true }) - await mkdir(TARGET_DIR, { recursive: true }) - - const tarballPath = path.join(TARGET_DIR, TARBALL_NAME) - try { - await download(RELEASE_URL, tarballPath) - const actual = await sha256(tarballPath) - if (actual !== EXPECTED_SHA256) { - throw new Error( - `SHA-256 mismatch for ${TARBALL_NAME}: expected ${EXPECTED_SHA256}, got ${actual}` - ) - } - await extract(tarballPath, TARGET_DIR) - await rm(tarballPath, { force: true }) - - const extracted = await readdir(TARGET_DIR) - if (!extracted.some(name => name.endsWith('.whl'))) { - throw new Error( - `Extraction did not produce any wheels under ${TARGET_DIR}` - ) - } - - await writeFile(COMPLETE_MARKER, '') - } catch (err) { - // Leave no partial install behind, so the next run starts clean. - await rm(TARGET_DIR, { recursive: true, force: true }) - throw err - } - - console.log(`Pyodide ${PYODIDE_VERSION} ready at ${TARGET_DIR}`) -} - -main().catch(err => { - console.error(err) - process.exit(1) -}) diff --git a/services/web/scripts/fix_collaborator_refs_null.mjs b/services/web/scripts/fix_collaborator_refs_null.mjs deleted file mode 100644 index fbd4ac89a4..0000000000 --- a/services/web/scripts/fix_collaborator_refs_null.mjs +++ /dev/null @@ -1,175 +0,0 @@ -import minimist from 'minimist' -import { - db, - ObjectId, - READ_PREFERENCE_SECONDARY, -} from '../app/src/infrastructure/mongodb.mjs' -import lodash from 'lodash' - -const args = minimist(process.argv.slice(2), { - boolean: ['commit'], -}) - -const run = async () => { - try { - const projects = await db.projectAuditLogEntries - .find( - { - operation: 'collaborator-limit-exceeded', - timestamp: { - $gte: new Date('2025-03-26'), - $lt: new Date('2025-04-02'), - }, - }, - { - readPreference: READ_PREFERENCE_SECONDARY, - projection: { - _id: 1, - projectId: 1, - }, - } - ) - .toArray() - - const uniqueProjectIds = lodash.uniq( - projects.map(p => p.projectId.toString()) - ) - - console.log( - `Found ${uniqueProjectIds.length} projects where collaborator-limit-exceeded operation was logged in provided date range` - ) - - let readOnlyCount = 0 - let pendingReviewerCount = 0 - let reviewerCount = 0 - - for (const projectId of uniqueProjectIds) { - if (args.commit) { - const readOnlyUpdate = await db.projects.updateOne( - { - _id: new ObjectId(projectId), - readOnly_refs: null, - }, - { - $set: { - readOnly_refs: [], - }, - } - ) - if (readOnlyUpdate.modifiedCount > 0) { - console.log(`Updated readOnly_refs for project id ${projectId}`) - } - readOnlyCount += readOnlyUpdate.modifiedCount - } else { - const project = await db.projects.findOne( - { - _id: new ObjectId(projectId), - readOnly_refs: null, - }, - { - projection: { - _id: 1, - readOnly_refs: 1, - }, - } - ) - if (project) { - readOnlyCount++ - console.log( - `Dry run: Would update readOnly_refs for project id ${projectId}` - ) - } - } - - if (args.commit) { - const pendingReviewerUpdate = await db.projects.updateOne( - { - _id: new ObjectId(projectId), - pendingReviewer_refs: null, - }, - { - $set: { - pendingReviewer_refs: [], - }, - } - ) - if (pendingReviewerUpdate.modifiedCount > 0) { - console.log( - `Updated pendingReviewer_refs for project id ${projectId}` - ) - } - pendingReviewerCount += pendingReviewerUpdate.modifiedCount - } else { - const project = await db.projects.findOne( - { - _id: new ObjectId(projectId), - pendingReviewer_refs: null, - }, - { - projection: { - _id: 1, - pendingReviewer_refs: 1, - }, - } - ) - if (project) { - pendingReviewerCount++ - console.log( - `Dry run: Would update pendingReviewer_refs for project id ${projectId}` - ) - } - } - - if (args.commit) { - const reviewerUpdate = await db.projects.updateOne( - { - _id: new ObjectId(projectId), - reviewer_refs: null, - }, - { - $set: { - reviewer_refs: [], - }, - } - ) - reviewerCount += reviewerUpdate.modifiedCount - } else { - const project = await db.projects.findOne( - { - _id: new ObjectId(projectId), - reviewer_refs: null, - }, - { - projection: { - _id: 1, - reviewer_refs: 1, - }, - } - ) - if (project) { - reviewerCount++ - console.log( - `Dry run: Would update reviewer_refs for project id ${projectId}` - ) - } - } - } - - if (args.commit) { - console.log( - `Updated readOnly_refs for ${readOnlyCount} projects, pendingReviewer_refs for ${pendingReviewerCount} projects, and reviewer_refs for ${reviewerCount} projects.` - ) - } else { - console.log( - `Dry run: Would update readOnly_refs for ${readOnlyCount} projects, pendingReviewer_refs for ${pendingReviewerCount} projects, and reviewer_refs for ${reviewerCount} projects.` - ) - } - - process.exit(0) - } catch (err) { - console.error('Error while processing projects:', err) - process.exit(1) - } -} - -run() diff --git a/services/web/scripts/fix_comment_id.mjs b/services/web/scripts/fix_comment_id.mjs deleted file mode 100644 index 5fcc3d903f..0000000000 --- a/services/web/scripts/fix_comment_id.mjs +++ /dev/null @@ -1,86 +0,0 @@ -// @ts-check - -import minimist from 'minimist' -import DocstoreManager from '../app/src/Features/Docstore/DocstoreManager.mjs' -import { db, ObjectId } from '../app/src/infrastructure/mongodb.mjs' - -const OPTS = parseArgs() - -function usage() { - console.error('Usage: node fix_comment_id.mjs [--commit] PROJECT_ID...') -} - -function parseArgs() { - const args = minimist(process.argv.slice(2), { - boolean: ['commit'], - }) - if (args._.length === 0) { - usage() - process.exit(0) - } - return { - projectIds: args._, - commit: args.commit, - } -} - -/** - * @param {any} projectId - */ -async function processProject(projectId) { - console.log(`Processing project ${projectId}...`) - const docRanges = await DocstoreManager.promises.getAllRanges(projectId) - - let commentsUpdated = 0 - for (const doc of docRanges) { - const updateCommentsInDoc = await processDoc(doc) - commentsUpdated += updateCommentsInDoc - } - if (OPTS.commit) { - console.log(`${commentsUpdated} comments updated`) - } -} - -/** - * @param {any} doc - */ -async function processDoc(doc) { - let commentsUpdated = 0 - for (const comment of doc.ranges.comments ?? []) { - if (comment.op.t !== comment.id) { - console.log( - `updating comment id ${comment.id} to ${comment.op.t} in doc ${doc._id} ...` - ) - if (OPTS.commit) { - await db.docs.updateOne( - { _id: new ObjectId(doc._id) }, - { - $set: { - 'ranges.comments.$[element].id': new ObjectId(comment.op.t), - }, - }, - { - arrayFilters: [ - { 'element.op.t': { $eq: new ObjectId(comment.op.t) } }, - ], - } - ) - commentsUpdated += 1 - } else { - console.log( - `Would update comment id ${comment.id} to ${comment.op.t} (dry run)` - ) - } - } - } - return commentsUpdated -} - -// Main loop -for (const projectId of OPTS.projectIds) { - await processProject(projectId) -} -if (!OPTS.commit) { - console.log('This was a dry run. Rerun with --commit to apply changes') -} -process.exit(0) diff --git a/services/web/scripts/fix_group_invite_emails_to_lowercase.mjs b/services/web/scripts/fix_group_invite_emails_to_lowercase.mjs deleted file mode 100644 index 52b98597b4..0000000000 --- a/services/web/scripts/fix_group_invite_emails_to_lowercase.mjs +++ /dev/null @@ -1,75 +0,0 @@ -import { db } from '../app/src/infrastructure/mongodb.mjs' -import { batchedUpdate } from '@overleaf/mongo-utils/batchedUpdate.js' -import { scriptRunner } from './lib/ScriptRunner.mjs' - -const DRY_RUN = process.env.DRY_RUN !== 'false' - -console.log({ - DRY_RUN, -}) - -function anyInviteEmailHasUppercaseChars(subscription) { - return subscription.teamInvites.some(invite => { - return /[A-Z]/.test(invite.email) - }) -} - -async function processBatch(subscriptions) { - for (const subscription of subscriptions) { - if (anyInviteEmailHasUppercaseChars(subscription)) { - console.log('fixing emails in group invites for', subscription._id) - if (!DRY_RUN) { - await db.subscriptions.updateOne({ _id: subscription._id }, [ - { - $set: { - teamInvites: { - $map: { - input: '$teamInvites', - in: { - $mergeObjects: [ - '$$this', - { - email: { - $toLower: '$$this.email', - }, - }, - ], - }, - }, - }, - }, - }, - ]) - } - } - } -} - -async function main(trackProgress) { - const projection = { - _id: 1, - teamInvites: 1, - } - const query = { - 'teamInvites.0': { - $exists: true, - }, - } - await batchedUpdate( - db.subscriptions, - query, - processBatch, - projection, - undefined, - { trackProgress } - ) -} - -try { - await scriptRunner(main) - console.error('Done.') - process.exit(0) -} catch (error) { - console.error({ error }) - process.exit(1) -} diff --git a/services/web/scripts/fix_malformed_filetree.mjs b/services/web/scripts/fix_malformed_filetree.mjs deleted file mode 100644 index 0e0ea8438e..0000000000 --- a/services/web/scripts/fix_malformed_filetree.mjs +++ /dev/null @@ -1,306 +0,0 @@ -/** - * This script fixes problems found by the find_malformed_filetrees.js script. - * - * The script takes a single argument --logs pointing at the output of a - * previous run of the find_malformed_filetrees.js script. - * - * Alternatively, use an adhoc file: --logs=<(echo '{"projectId":"...","path":"..."}') - */ -import mongodb from 'mongodb-legacy' -import { db } from '../app/src/infrastructure/mongodb.mjs' -import ProjectLocator from '../app/src/Features/Project/ProjectLocator.mjs' -import minimist from 'minimist' -import readline from 'node:readline' -import fs from 'node:fs' -import logger from '@overleaf/logger' -import { scriptRunner } from './lib/ScriptRunner.mjs' - -const { ObjectId } = mongodb -const { findDeep } = ProjectLocator -const lastUpdated = new Date() - -const argv = minimist(process.argv.slice(2), { - string: ['logs'], -}) - -let gracefulShutdownInitiated = false - -process.on('SIGINT', handleSignal) -process.on('SIGTERM', handleSignal) - -function handleSignal() { - gracefulShutdownInitiated = true - console.warn('graceful shutdown initiated, draining queue') -} - -const STATS = { - processedLines: 0, - success: 0, - alreadyProcessed: 0, - hash: 0, - failed: 0, - unmatched: 0, -} -function logStats() { - console.log( - JSON.stringify({ - time: new Date(), - gracefulShutdownInitiated, - ...STATS, - }) - ) -} -setInterval(logStats, 10_000) - -async function main() { - const rl = readline.createInterface({ - input: fs.createReadStream(argv.logs), - }) - for await (const line of rl) { - if (gracefulShutdownInitiated) break - STATS.processedLines++ - if (!line.startsWith('{')) continue - try { - const { projectId, path, _id } = JSON.parse(line) - await processBadPath(projectId, path, _id) - } catch (err) { - STATS.failed++ - logger.err({ line, err }, 'failed to fix tree') - } - } -} - -async function processBadPath(projectId, mongoPath, _id) { - let modifiedCount - if (isRootFolder(mongoPath)) { - modifiedCount = await fixRootFolder(projectId) - } else if (isArrayElement(mongoPath)) { - modifiedCount = await removeNulls(projectId, _id) - } else if (isArray(mongoPath)) { - modifiedCount = await fixArray(projectId, mongoPath) - } else if (isFolderId(mongoPath)) { - modifiedCount = await fixFolderId(projectId, mongoPath) - } else if (isDocOrFileId(mongoPath)) { - modifiedCount = await removeElementsWithoutIds( - projectId, - parentPath(parentPath(mongoPath)) - ) - } else if (isName(mongoPath)) { - modifiedCount = await fixName(projectId, _id) - } else if (isHash(mongoPath)) { - console.error(`Missing file hash: ${projectId}/${_id} (${mongoPath})`) - console.error('SaaS: likely needs filestore restore') - console.error('Server Pro: please reach out to support') - STATS.hash++ - return - } else { - console.error(`Unexpected mongo path: ${mongoPath}`) - STATS.unmatched++ - return - } - - if (modifiedCount === 0) { - STATS.alreadyProcessed++ - } else { - STATS.success++ - } -} - -function isRootFolder(path) { - return path === 'rootFolder.0' -} - -function isArray(path) { - return /\.(docs|folders|fileRefs)$/.test(path) -} - -function isArrayElement(path) { - return /\.\d+$/.test(path) -} - -function isFolderId(path) { - return /\.folders\.\d+\._id$/.test(path) -} - -function isDocOrFileId(path) { - return /\.(docs|fileRefs)\.\d+\._id$/.test(path) -} - -function isName(path) { - return /\.name$/.test(path) -} - -function isHash(path) { - return /\.hash$/.test(path) -} - -function parentPath(path) { - return path.slice(0, path.lastIndexOf('.')) -} - -/** - * If the root folder structure is missing, set it up - */ -async function fixRootFolder(projectId) { - const result = await db.projects.updateOne( - { - _id: new ObjectId(projectId), - rootFolder: { $size: 0 }, - }, - { - $set: { - rootFolder: [ - { - _id: new ObjectId(), - name: 'rootFolder', - folders: [], - docs: [], - fileRefs: [], - }, - ], - lastUpdated, - lastUpdatedBy: null, // unset lastUpdatedBy - }, - } - ) - return result.modifiedCount -} - -/** - * Remove all nulls from the given docs/files/folders array - */ -async function removeNulls(projectId, _id) { - if (!_id) { - throw new Error('missing _id') - } - const project = await db.projects.findOne( - { _id: new ObjectId(projectId) }, - { projection: { rootFolder: 1 } } - ) - const foundResult = findDeep(project, obj => obj?._id?.toString() === _id) - if (!foundResult) return - const { path } = foundResult - const result = await db.projects.updateOne( - { _id: new ObjectId(projectId) }, - { - $pull: { - [`${path}.folders`]: null, - [`${path}.docs`]: null, - [`${path}.fileRefs`]: null, - }, - $set: { - lastUpdated, - lastUpdatedBy: null, // unset lastUpdatedBy - }, - } - ) - return result.modifiedCount -} - -/** - * If the element at the given path is not an array, set it to an empty array - */ -async function fixArray(projectId, path) { - const result = await db.projects.updateOne( - { _id: new ObjectId(projectId), [path]: { $not: { $type: 'array' } } }, - { $set: { [path]: [], lastUpdated, lastUpdatedBy: null } } - ) - return result.modifiedCount -} - -/** - * Generate a missing id for a folder - */ -async function fixFolderId(projectId, path) { - const result = await db.projects.updateOne( - { _id: new ObjectId(projectId), [path]: { $exists: false } }, - { - $set: { - [path]: new ObjectId(), - lastUpdated, - lastUpdatedBy: null, // unset lastUpdatedBy - }, - } - ) - return result.modifiedCount -} - -/** - * Remove elements that don't have ids in the array at the given path - */ -async function removeElementsWithoutIds(projectId, path) { - const result = await db.projects.updateOne( - { _id: new ObjectId(projectId), [path]: { $type: 'array' } }, - { - $pull: { [path]: { _id: null } }, - $set: { - lastUpdated, - lastUpdatedBy: null, // unset lastUpdatedBy - }, - } - ) - return result.modifiedCount -} - -/** - * Give a name to a file/doc/folder that doesn't have one - */ -async function fixName(projectId, _id) { - if (!_id) { - throw new Error('missing _id') - } - const project = await db.projects.findOne( - { _id: new ObjectId(projectId) }, - { projection: { rootFolder: 1 } } - ) - const foundResult = findDeep(project, obj => obj?._id?.toString() === _id) - if (!foundResult) return - const { path } = foundResult - const array = ProjectLocator.findElementByMongoPath(project, parentPath(path)) - const name = - path === 'rootFolder.0' - ? 'rootFolder' - : findUniqueName(new Set(array.map(x => x?.name))) - const pathToName = `${path}.name` - const result = await db.projects.updateOne( - { _id: new ObjectId(projectId), [pathToName]: { $in: [null, ''] } }, - { - $set: { - [pathToName]: name, - lastUpdated, - lastUpdatedBy: null, // unset lastUpdatedBy - }, - } - ) - return result.modifiedCount -} - -function findUniqueName(existingFilenames) { - let index = 0 - let filename = 'untitled' - while (existingFilenames.has(filename)) { - index += 1 - filename = `untitled-${index}` - } - return filename -} - -try { - try { - await scriptRunner(main) - } finally { - logStats() - } - if (STATS.failed > 0) { - process.exit(Math.min(STATS.failed, 99)) - } else if (STATS.hash > 0) { - process.exit(100) - } else if (STATS.unmatched > 0) { - process.exit(101) - } else { - process.exit(0) - } -} catch (error) { - console.error(error) - process.exit(1) -} diff --git a/services/web/scripts/fix_unconfirmed_secondaries_not_removed_v1.mjs b/services/web/scripts/fix_unconfirmed_secondaries_not_removed_v1.mjs deleted file mode 100644 index 76aaddd3d7..0000000000 --- a/services/web/scripts/fix_unconfirmed_secondaries_not_removed_v1.mjs +++ /dev/null @@ -1,261 +0,0 @@ -// @ts-check - -import { db, ObjectId } from '../app/src/infrastructure/mongodb.mjs' -import fs from 'node:fs/promises' -import * as csv from 'csv' -import { promisify } from 'node:util' -import { scriptRunner } from './lib/ScriptRunner.mjs' -import { READ_PREFERENCE_SECONDARY } from '@overleaf/mongo-utils/batchedUpdate.js' -import { fetchJson, fetchNothing } from '@overleaf/fetch-utils' -import Settings from '@overleaf/settings' -import path from 'path-browserify' -import { fileURLToPath } from 'node:url' -import minimist from 'minimist' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const CSV_FILENAME = './tmp/unconfirmed_emails_removed.csv' - -const argv = minimist(process.argv.slice(2)) -const commit = argv.commit === 'true' -const doNotListUsers = argv.do_not_list_users === 'true' - -console.log( - 'Begin remove affiliations not removed in v1 when unconfirmed secondary email was removed from user account' -) - -if (commit) { - console.log('\nRunning in COMMIT mode, changes will be made in v1\n') -} else { - console.log( - '\nRunning in DRY-RUN mode, no changes will be made in v1, only reporting results. To commit changes run with --commit=true\n' - ) -} - -if (!doNotListUsers) { - console.log( - 'Full results lists will be outputed. To not list users run with --do_not_list_users=true\n' - ) -} - -/** - * @type {(csvString: string) => Promise} - */ -const parseAsync = promisify(csv.parse) - -/** - * @param {any} userId - */ -async function getV1Affiliations(userId) { - const url = `${Settings.apis.v1.url}/api/v2/users/${userId}/affiliations` - - const affiliations = await fetchJson(url, { - basicAuth: { - user: Settings.apis.v1.user, - password: Settings.apis.v1.pass, - }, - signal: AbortSignal.timeout(Settings.apis.v1.timeout), - }) - - return affiliations -} - -/** - * @param {any} userId - * @param {any} email - */ -async function removeAffiliationV1(userId, email) { - const url = `${Settings.apis.v1.url}/api/v2/users/${userId}/affiliations/remove` - - await fetchNothing(url, { - method: 'POST', - json: { email }, - basicAuth: { - user: Settings.apis.v1.user, - password: Settings.apis.v1.pass, - }, - signal: AbortSignal.timeout(Settings.apis.v1.timeout), - defaultErrorMessage: "Couldn't remove affiliation", - }) -} - -const results = { - /** @type {string[]} */ - userNotFound: [], - /** @type {string[]} */ - userNotFoundInV1: [], - /** @type {{email: string, userId: string}[]} */ - emailNotInV1ForUser: [], - /** @type {{email: string, userId: string}[]} */ - needToRemoveEmailInV1: [], - /** @type {{email: string, userId: string}[]} */ - successfullyRemovedEmailInV1ForUser: [], - /** @type {{email: string, userId: string}[]} */ - emailStillOnAccount: [], - /** @type {{email: string, userId: string}[]} */ - emailNowOnOtherAccount: [], - /** @type {string[]} */ - errorCheckingAffiliations: [], - /** @type {{email: string, userId: string, status?: number}[]} */ - errorRemovingAffiliationInV1: [], -} - -/** - * @param {any} trackProgress - */ -async function main(trackProgress) { - console.time('check_removed_emails') - - const filePath = path.join(__dirname, CSV_FILENAME) - const csvContent = await fs.readFile(filePath, 'utf8') - const rows = await parseAsync(csvContent) - rows.shift() // Remove header row - /** @type {Record} */ - const emailsByUserId = {} - - for (const [userId, email] of rows) { - if (!emailsByUserId[userId]) { - emailsByUserId[userId] = [] - } - emailsByUserId[userId].push(email.trim()) - } - - const userIds = Object.keys(emailsByUserId) - let processedUsersCount = 0 - - console.log('Total emails in the CSV:', rows.length) - console.log('Total users in the CSV:', userIds.length) - - for (const userId of userIds) { - const removedEmails = emailsByUserId[userId] // these will be emails that had affiliations and not. they were removed from the user account, but we didn't remove the affiliations in v1 (if they existed) - let affiliations - try { - affiliations = await getV1Affiliations(userId) - if (!affiliations.length) { - results.userNotFoundInV1.push(userId) - // nothing to cleanup in v1 if no affiliations for the user - continue - } - } catch (/** @type {any} */ e) { - results.errorCheckingAffiliations.push(userId) - } - - const affiliationsEmailsInV1 = affiliations.map( - (/** @type {any} */ affiliation) => affiliation.email - ) - - const user = await db.users.findOne( - { _id: new ObjectId(userId) }, - { readPreference: READ_PREFERENCE_SECONDARY, projection: { emails: 1 } } - ) - - if (!user) { - // user was deleted but their affiliations still persist in v1, - // we should cleanup v1, otherwise email cannot be added to other accounts - results.userNotFound.push(userId) - } - - for (const email of removedEmails) { - if (!affiliationsEmailsInV1.includes(email)) { - // the email removed is not in v1 affiliations for the user ID it was removed from, - // this is expected and good (either email had no affiliation or somehow the affiliation did get removed), no cleanup needed in v1 - results.emailNotInV1ForUser.push({ userId, email }) - continue - } - - const emailOnAccount = user?.emails?.find( - (/** @type {any} */ e) => e.email === email - ) - - if (emailOnAccount) { - // the email is still on the user account, we should not remove the affiliation in v1 - results.emailStillOnAccount.push({ userId, email }) - continue - } else { - // we'll remove the email affiliation in v1 but let's also check if the email is now on another user's account - // this should error but if it did get added then it would be added without an affiliation - // possibly ok because maybe email is not affiliated, but worth investigating if this happened because if it was added without an affiliation then that - // could put the user into a bad state (no access to Commons license, no visibility in metrics, not captured by group with domain capture, etc) - const query = { emails: { $exists: true }, 'emails.email': email } // $exists: true MUST be set to use the partial index - const otherUserWithEmail = await db.users.findOne(query, { - readPreference: READ_PREFERENCE_SECONDARY, - }) - if (otherUserWithEmail) { - results.emailNowOnOtherAccount.push({ - email, - userId: otherUserWithEmail._id.toString(), - }) - } - } - - results.needToRemoveEmailInV1.push({ userId, email }) - if (commit) { - // only make the changes if script arg is 'commit', otherwise just report results - try { - // remove the affiliation in v1 - await removeAffiliationV1(userId, email) - results.successfullyRemovedEmailInV1ForUser.push({ userId, email }) - } catch (/** @type {any} */ e) { - results.errorRemovingAffiliationInV1.push({ - userId, - email, - // @ts-ignore - status: e.info?.status, - }) - } - } - } - - processedUsersCount++ - if (processedUsersCount % 100 === 0) { - trackProgress(`Processed ${processedUsersCount} users`) - } - } - - console.log('Results:') - for (const key in results) { - console.log(` ${key}:`, /** @type {any} */ (results)[key].length) - } - for (const key in results) { - if ( - !doNotListUsers && - /** @type {any} */ (results)[key].length > 0 && - key !== 'needToRemoveEmailInV1' - ) { - // skip needToRemoveEmailInV1 since we'll only output that if this list length does not match success list length - console.log('----------------------------') - console.log(`${key}:`) - console.log(/** @type {any} */ (results)[key]) - } - } - - if ( - commit && - !doNotListUsers && - results.needToRemoveEmailInV1.length !== - results.successfullyRemovedEmailInV1ForUser.length - ) { - // avoid outputting this list twice since it will be quite long. Only output those that need to be removed and were not successfully removed - - const expectedToBeRemovedButWerent = results.needToRemoveEmailInV1.filter( - needToRemove => - !results.successfullyRemovedEmailInV1ForUser.some( - successfullyRemoved => - successfullyRemoved.userId === needToRemove.userId && - successfullyRemoved.email === needToRemove.email - ) - ) - - console.log( - '----------------------------\nEmails that needed to be removed in v1 but were not successfully removed:' - ) - console.log(expectedToBeRemovedButWerent) - } -} - -try { - await scriptRunner(main) - process.exit(0) -} catch (error) { - console.error(error) - process.exit(1) -} diff --git a/services/web/scripts/helpers/migrate_recurly_customers_to_stripe.helpers.mjs b/services/web/scripts/helpers/migrate_recurly_customers_to_stripe.helpers.mjs deleted file mode 100644 index 0469dc0776..0000000000 --- a/services/web/scripts/helpers/migrate_recurly_customers_to_stripe.helpers.mjs +++ /dev/null @@ -1,1621 +0,0 @@ -/* eslint-disable @overleaf/require-script-runner */ - -/* - * - * This file can be deleted once the Recurly to Stripe migration is complete. - */ - -export function coalesceOrEqualOrThrow(a, b, fieldName) { - const isSetA = !!a - const isSetB = !!b - - if (isSetA && isSetB && a !== b) { - throw new Error( - `Field ${fieldName}: Primary and fallback values are both set but differ (${a} != ${b})` - ) - } - - return isSetA ? a : b -} - -export function normalizeName(firstName, lastName) { - const first = (firstName || '').trim() - const last = (lastName || '').trim() - const full = `${first} ${last}`.trim() - return full || null -} - -/** - * Extract customer name from billing info only (no fallback to account). - * - * @param {object} account - Recurly account object - * @returns {string|null} - */ -export function extractNameFromBillingInfo(account) { - const billingHasFullName = !!( - account.billingInfo?.firstName && account.billingInfo?.lastName - ) - return billingHasFullName - ? normalizeName(account.billingInfo.firstName, account.billingInfo.lastName) - : null -} - -/** - * Extract customer name from account level only (no fallback to billing info). - * - * @param {object} account - Recurly account object - * @returns {string|null} - */ -export function extractNameFromAccount(account) { - // some accounts have only a firstName field populated with the full name, so normalizeName falls back to just firstName if lastName is missing - return normalizeName(account.firstName, account.lastName) -} - -/** - * Extract and coalesce VAT number from Recurly data. - * - * Coalesce/equality behavior: - * - Prefer billingInfo.vatNumber when set. - * - Fall back to account.vatNumber otherwise. - * - If both are set but differ, throw. - * - * @param {object} account - Recurly account object - * @returns {string|null} - */ -export function coalesceOrThrowVATNumber(account) { - const billingVat = account.billingInfo?.vatNumber?.trim() || null - const accountVat = account?.vatNumber?.trim() || null - return coalesceOrEqualOrThrow(billingVat, accountVat, 'vatNumber') -} - -/** - * Normalize a Recurly address into a Stripe AddressParam. - * - * Recurly address field names appear in multiple shapes depending on SDK/version - * and serialization (e.g. street1/street2/postalCode vs line1/line2/postal_code). - * - * @param {any} address - * @returns {import('stripe').Stripe.AddressParam|null} - */ -export function normalizeRecurlyAddressToStripe(address) { - if (!address) return null - - const line1 = (address.street1 || '').trim() - // eslint-disable-next-line camelcase - const postal_code = (address.postalCode || '').trim() - const country = String(address.country || '') - .trim() - .toUpperCase() - - // Only send an address if it has enough data to be plausibly accepted/usable by Stripe. - // For now we'll accept just bare minimum of a country code - if (!/^[A-Z]{2}$/.test(country)) return null - - const line2 = (address.street2 || '').trim() - const city = (address.city || '').trim() - const state = (address.region || '').trim() - - // Intentionally include empty-string fields so Stripe clears any existing - // stale values on the customer address when Recurly has blanks. - return { - line1, - line2, - city, - state, - // eslint-disable-next-line camelcase - postal_code, - country, - } -} - -/** - * EU member state country codes for VAT purposes. - */ -export const EU_VAT_COUNTRIES = [ - 'AT', // Austria - 'BE', // Belgium - 'BG', // Bulgaria - 'CY', // Cyprus - 'CZ', // Czech Republic - 'DE', // Germany - 'DK', // Denmark - 'EE', // Estonia - 'ES', // Spain - 'FI', // Finland - 'FR', // France - 'GR', // Greece - 'HR', // Croatia - 'HU', // Hungary - 'IE', // Ireland - 'IT', // Italy - 'LT', // Lithuania - 'LU', // Luxembourg - 'LV', // Latvia - 'MT', // Malta - 'NL', // Netherlands - 'PL', // Poland - 'PT', // Portugal - 'RO', // Romania - 'SE', // Sweden - 'SI', // Slovenia - 'SK', // Slovakia -] - -const EU_VAT_PREFIX_OVERRIDES = { - GR: 'EL', // Greece uses EL prefix for VAT numbers -} - -function normalizeTaxId(value) { - if (!value) return '' - return String(value).trim().toUpperCase() -} - -function normalizeTaxIdCompact(value) { - return normalizeTaxId(value).replace(/\s+/g, '') -} - -function normalizeTaxIdAlnum(value) { - // Keep alphanumerics and '&' (used by some MX RFC values), strip separators. - return normalizeTaxId(value).replace(/[^A-Z0-9&]+/g, '') -} - -function digitsOnly(value) { - return normalizeTaxId(value).replace(/\D+/g, '') -} - -function hasEuVatPrefix(country, taxIdValue) { - const normalized = normalizeTaxIdCompact(taxIdValue) - if (!normalized) return false - const prefix = EU_VAT_PREFIX_OVERRIDES[country] || country - return normalized.startsWith(prefix) -} - -function caProvinceFromPostalCode(postalCode) { - if (!postalCode) return null - const m = String(postalCode) - .trim() - .toUpperCase() - .match(/^([A-Z])/) - if (!m) return null - const c = m[1] - - if (c === 'A') return 'NL' - if (c === 'B') return 'NS' - if (c === 'C') return 'PE' - if (c === 'E') return 'NB' - if (c === 'G' || c === 'H' || c === 'J') return 'QC' - if (c === 'K' || c === 'L' || c === 'M' || c === 'N' || c === 'P') return 'ON' - if (c === 'R') return 'MB' - if (c === 'S') return 'SK' - if (c === 'T') return 'AB' - if (c === 'V') return 'BC' - if (c === 'Y') return 'YT' - if (c === 'X') return 'NT_NU' // ambiguous without more info - return null -} - -/** - * Determine the Stripe tax ID type for Canada based on the tax ID value format. - * - * Source reference: - * - Stripe docs list supported types + example formats (Canada section) - * https://docs.stripe.com/billing/customer/tax-ids - * - * - * If the value doesn't clearly match one of Stripe's documented example formats, - * return null so the caller can handle it manually. - * - * @param {string|undefined|null} taxIdValue - * @returns {'ca_bn'|'ca_gst_hst'|'ca_pst_bc'|'ca_pst_mb'|'ca_pst_sk'|'ca_qst'|null} - */ -export function getCanadaTaxIdType(taxIdValue, postalCode) { - if (!taxIdValue) return null - - // Normalize for CRA/Stripe examples which may contain spaces (e.g. "123456789 RT 0001") - const normalized = String(taxIdValue).trim().toUpperCase().replace(/\s+/g, '') - if (!normalized) return null - - // GST/HST: CRA defines program account numbers as BN (9 digits) + program id (2 letters) + reference (4 digits). - // CRA explicitly shows GST/HST program account number as: 123456789 RT 0001 (or without spaces). - // Safe because "RT" in this exact position is the GST/HST program identifier. - // source: https://www.canada.ca/en/revenue-agency/services/tax/businesses/topics/business-registration/business-number-program-account/need-program-accounts.htm - if (/^\d{9}RT\d{4}$/.test(normalized)) return 'ca_gst_hst' - - // Québec QST: Stripe support doc gives exact structure and example 1234567891TQ0001, - // and Revenu Québec states QST registration numbers include the letters "TQ". - // Safe because "TQ" in this exact position is distinctive. - // source: https://support.stripe.com/questions/quebec-sales-tax-information - if (/^\d{10}TQ\d{4}$/.test(normalized)) return 'ca_qst' - - // British Columbia PST: BC Gov explicitly states PST number format is PST-1234-5678. - // Safe because it has the "PST-" prefix + hyphen groups. - // source: https://www2.gov.bc.ca/gov/content/taxes/sales-taxes/pst/register - if (/^PST-\d{4}-\d{4}$/.test(normalized)) return 'ca_pst_bc' - - const prov = caProvinceFromPostalCode(postalCode) - - // Ambiguous numeric-only cases: require province inferred from postal code - // Manitoba: allow dashed 123456-7 and undashed 1234567, but ONLY if prov==MB - if ( - (/^\d{6}-\d$/.test(normalized) || /^\d{7}$/.test(normalized)) && - prov === 'MB' - ) { - return 'ca_pst_mb' - } - - // Saskatchewan: 7 digits ONLY if prov==SK - if (/^\d{7}$/.test(normalized) && prov === 'SK') { - return 'ca_pst_sk' - } - - // Canadian BN: CRA says the GST/HST program account number starts with a 9-digit BN, and some workflows ask for only those 9 digits. - // Stripe has ca_bn, but a bare 9-digit value is ambiguous (BN-only vs “first 9 digits of GST/HST account” vs other). - // -> Not safe to classify from format alone. Only classify as ca_bn if your *input field* is explicitly “BN”. - // if (/^\d{9}$/.test(normalized)) return 'ca_bn' - - return null -} - -/** - * Determine the Stripe tax ID type for Australia based on the tax ID value. - * - * Source reference: - * - Stripe docs list supported types + example formats (Australia section) - * https://docs.stripe.com/billing/customer/tax-ids - * - * @param {string|undefined|null} taxIdValue - * @returns {'au_abn'|'au_arn'|null} - */ -export function getAustraliaTaxIdType(taxIdValue) { - const digits = digitsOnly(taxIdValue) - if (!digits) return null - - // ABN: 11 digits (example: 12345678912) - if (/^\d{11}$/.test(digits)) return 'au_abn' - - // ARN: 12 digits (example: 123456789123) - if (/^\d{12}$/.test(digits)) return 'au_arn' - - return null -} - -/** - * Determine the Stripe tax ID type for Brazil based on the tax ID value. - * - * Source reference: - * - Stripe docs list supported types + example formats (Brazil section) - * https://docs.stripe.com/billing/customer/tax-ids - * - * @param {string|undefined|null} taxIdValue - * @returns {'br_cnpj'|'br_cpf'|null} - */ -export function getBrazilTaxIdType(taxIdValue) { - const digits = digitsOnly(taxIdValue) - if (!digits) return null - - // CNPJ: 14 digits (example: 01.234.456/5432-10) - if (/^\d{14}$/.test(digits)) return 'br_cnpj' - - // CPF: 11 digits (example: 123.456.789-87) - if (/^\d{11}$/.test(digits)) return 'br_cpf' - - return null -} - -/** - * Determine the Stripe tax ID type for Bulgaria (EU VAT vs UIC). - * - * Source reference: - * - Stripe docs list supported types + example formats (Bulgaria section) - * https://docs.stripe.com/billing/customer/tax-ids - * - * @param {string|undefined|null} taxIdValue - * @returns {'eu_vat'|'bg_uic'|null} - */ -export function getBulgariaTaxIdType(taxIdValue) { - if (!taxIdValue) return null - - // EU VAT: BG prefix (example: BG0123456789) - if (hasEuVatPrefix('BG', taxIdValue)) return 'eu_vat' - - // UIC: 9 digits (example: 123456789) - if (/^\d{9}$/.test(digitsOnly(taxIdValue))) return 'bg_uic' - - return null -} - -/** - * Determine the Stripe tax ID type for Croatia (EU VAT vs OIB). - * - * Source reference: - * - Stripe docs list supported types + example formats (Croatia section) - * https://docs.stripe.com/billing/customer/tax-ids - * - * @param {string|undefined|null} taxIdValue - * @returns {'eu_vat'|'hr_oib'|null} - */ -export function getCroatiaTaxIdType(taxIdValue) { - if (!taxIdValue) return null - - // EU VAT: HR prefix (example: HR12345678912) - if (hasEuVatPrefix('HR', taxIdValue)) return 'eu_vat' - - // OIB: 11 digits (example: 12345678901) - if (/^\d{11}$/.test(digitsOnly(taxIdValue))) return 'hr_oib' - - return null -} - -/** - * Determine the Stripe tax ID type for Germany (EU VAT vs Steuer Nummer). - * - * Source reference: - * - Stripe docs list supported types + example formats (Germany section) - * https://docs.stripe.com/billing/customer/tax-ids - * - * @param {string|undefined|null} taxIdValue - * @returns {'eu_vat'|'de_stn'|null} - */ -export function getGermanyTaxIdType(taxIdValue) { - if (!taxIdValue) return null - - // EU VAT: DE prefix (example: DE123456789) - if (hasEuVatPrefix('DE', taxIdValue)) return 'eu_vat' - - // Steuer Nummer: 10 digits (example: 1234567890) - if (/^\d{10}$/.test(digitsOnly(taxIdValue))) return 'de_stn' - - return null -} - -/** - * Determine the Stripe tax ID type for Hungary (EU VAT vs HU tax number). - * - * Source reference: - * - Stripe docs list supported types + example formats (Hungary section) - * https://docs.stripe.com/billing/customer/tax-ids - * - * @param {string|undefined|null} taxIdValue - * @returns {'eu_vat'|'hu_tin'|null} - */ -export function getHungaryTaxIdType(taxIdValue) { - if (!taxIdValue) return null - - // EU VAT: HU prefix (example: HU12345678) - if (hasEuVatPrefix('HU', taxIdValue)) return 'eu_vat' - - // HU tax number: 8-1-2 digits (example: 12345678-1-23) - const normalized = normalizeTaxIdCompact(taxIdValue) - if (/^\d{8}-\d-\d{2}$/.test(normalized)) return 'hu_tin' - - return null -} - -/** - * Determine the Stripe tax ID type for Japan. - * - * Source reference: - * - Stripe docs list supported types + example formats (Japan section) - * https://docs.stripe.com/billing/customer/tax-ids - * - * @param {string|undefined|null} taxIdValue - * @returns {'jp_cn'|'jp_rn'|'jp_trn'|null} - */ -export function getJapanTaxIdType(taxIdValue) { - const normalized = normalizeTaxIdCompact(taxIdValue) - if (!normalized) return null - - // Tax Registration Number: T + 13 digits (example: T1234567891234) - if (/^T\d{13}$/.test(normalized)) return 'jp_trn' - - // Corporate Number: 13 digits (example: 1234567891234) - if (/^\d{13}$/.test(normalized)) return 'jp_cn' - - // Registered Foreign Businesses: 5 digits (example: 12345) - if (/^\d{5}$/.test(normalized)) return 'jp_rn' - - return null -} - -/** - * Determine the Stripe tax ID type for Liechtenstein (UID vs VAT). - * - * Source reference: - * - Stripe docs list supported types + example formats (Liechtenstein section) - * https://docs.stripe.com/billing/customer/tax-ids - * - * @param {string|undefined|null} taxIdValue - * @returns {'li_uid'|'li_vat'|null} - */ -export function getLiechtensteinTaxIdType(taxIdValue) { - const normalized = normalizeTaxIdCompact(taxIdValue) - if (!normalized) return null - - // UID: CHE + 9 digits (example: CHE123456789) - if (/^CHE\d{9}$/.test(normalized)) return 'li_uid' - - // VAT: 5 digits (example: 12345) - if (/^\d{5}$/.test(digitsOnly(normalized))) return 'li_vat' - - return null -} - -/** - * Determine the Stripe tax ID type for Malaysia (FRP, ITN, SST). - * - * Source reference: - * - Stripe docs list supported types + example formats (Malaysia section) - * https://docs.stripe.com/billing/customer/tax-ids - * - * @param {string|undefined|null} taxIdValue - * @returns {'my_frp'|'my_itn'|'my_sst'|null} - */ -export function getMalaysiaTaxIdType(taxIdValue) { - const normalized = normalizeTaxIdCompact(taxIdValue) - if (!normalized) return null - - // FRP: 8 digits (example: 12345678) - if (/^\d{8}$/.test(digitsOnly(normalized))) return 'my_frp' - - // ITN: letter + 10 digits (example: C 1234567890) - if (/^[A-Z]\d{10}$/.test(normalized.replace(/\s+/g, ''))) return 'my_itn' - - // SST: A12-3456-78912345 - if (/^[A-Z]\d{2}-\d{4}-\d{8}$/.test(normalized)) return 'my_sst' - - return null -} - -/** - * Determine the Stripe tax ID type for Norway (VAT vs VOEC). - * - * Source reference: - * - Stripe docs list supported types + example formats (Norway section) - * https://docs.stripe.com/billing/customer/tax-ids - * - * @param {string|undefined|null} taxIdValue - * @returns {'no_vat'|'no_voec'|null} - */ -export function getNorwayTaxIdType(taxIdValue) { - const normalized = normalizeTaxIdCompact(taxIdValue) - if (!normalized) return null - - // VAT: 9 digits + MVA (example: 123456789MVA) - if (/^\d{9}MVA$/.test(normalized)) return 'no_vat' - - // VOEC: 7 digits (example: 1234567) - if (/^\d{7}$/.test(digitsOnly(normalized))) return 'no_voec' - - return null -} - -/** - * Determine the Stripe tax ID type for Poland (EU VAT vs NIP). - * - * Source reference: - * - Stripe docs list supported types + example formats (Poland section) - * https://docs.stripe.com/billing/customer/tax-ids - * - * @param {string|undefined|null} taxIdValue - * @returns {'eu_vat'|'pl_nip'|null} - */ -export function getPolandTaxIdType(taxIdValue) { - if (!taxIdValue) return null - - // EU VAT: PL prefix (example: PL1234567890) - if (hasEuVatPrefix('PL', taxIdValue)) return 'eu_vat' - - // NIP: 10 digits (example: 1234567890) - if (/^\d{10}$/.test(digitsOnly(taxIdValue))) return 'pl_nip' - - return null -} - -/** - * Determine the Stripe tax ID type for Romania (EU VAT vs TIN). - * - * Source reference: - * - Stripe docs list supported types + example formats (Romania section) - * https://docs.stripe.com/billing/customer/tax-ids - * - * @param {string|undefined|null} taxIdValue - * @returns {'eu_vat'|'ro_tin'|null} - */ -export function getRomaniaTaxIdType(taxIdValue) { - if (!taxIdValue) return null - - // EU VAT: RO prefix (example: RO1234567891) - if (hasEuVatPrefix('RO', taxIdValue)) return 'eu_vat' - - // TIN: 13 digits (example: 1234567890123) - if (/^\d{13}$/.test(digitsOnly(taxIdValue))) return 'ro_tin' - - return null -} - -/** - * Determine the Stripe tax ID type for Russia (INN vs KPP). - * - * Source reference: - * - Stripe docs list supported types + example formats (Russia section) - * https://docs.stripe.com/billing/customer/tax-ids - * - * @param {string|undefined|null} taxIdValue - * @returns {'ru_inn'|'ru_kpp'|null} - */ -export function getRussiaTaxIdType(taxIdValue) { - const digits = digitsOnly(taxIdValue) - if (!digits) return null - - // INN: 10 or 12 digits (example: 1234567891) - if (/^(\d{10}|\d{12})$/.test(digits)) return 'ru_inn' - - // KPP: 9 digits (example: 123456789) - if (/^\d{9}$/.test(digits)) return 'ru_kpp' - - return null -} - -/** - * Determine the Stripe tax ID type for Singapore (GST vs UEN). - * - * Source reference: - * - Stripe docs list supported types + example formats (Singapore section) - * https://docs.stripe.com/billing/customer/tax-ids - * - * @param {string|undefined|null} taxIdValue - * @returns {'sg_gst'|'sg_uen'|null} - */ -export function getSingaporeTaxIdType(taxIdValue) { - const normalized = normalizeTaxIdCompact(taxIdValue) - if (!normalized) return null - - // GST: M + 8 digits + letter (example: M12345678X) - if (/^M\d{8}[A-Z]$/.test(normalized)) return 'sg_gst' - - // UEN: 9 digits + letter (example: 123456789F) - if (/^\d{9}[A-Z]$/.test(normalized)) return 'sg_uen' - - return null -} - -/** - * Determine the Stripe tax ID type for Slovenia (EU VAT vs TIN). - * - * Source reference: - * - Stripe docs list supported types + example formats (Slovenia section) - * https://docs.stripe.com/billing/customer/tax-ids - * - * @param {string|undefined|null} taxIdValue - * @returns {'eu_vat'|'si_tin'|null} - */ -export function getSloveniaTaxIdType(taxIdValue) { - if (!taxIdValue) return null - - // EU VAT: SI prefix (example: SI12345678) - if (hasEuVatPrefix('SI', taxIdValue)) return 'eu_vat' - - // TIN: 8 digits (example: 12345678) - if (/^\d{8}$/.test(digitsOnly(taxIdValue))) return 'si_tin' - - return null -} - -/** - * Determine the Stripe tax ID type for Spain (EU VAT vs CIF/NIF). - * - * Source reference: - * - Stripe docs list supported types + example formats (Spain section) - * https://docs.stripe.com/billing/customer/tax-ids - * - * @param {string|undefined|null} taxIdValue - * @returns {'eu_vat'|'es_cif'|null} - */ -export function getSpainTaxIdType(taxIdValue) { - const normalized = normalizeTaxIdCompact(taxIdValue) - if (!normalized) return null - - // EU VAT: ES prefix (example: ESA1234567Z) - if (hasEuVatPrefix('ES', normalized)) return 'eu_vat' - - // CIF/NIF: A12345678 (letter + 7 digits + alnum) - if (/^[A-Z]\d{7}[A-Z0-9]$/.test(normalized)) return 'es_cif' - - return null -} - -/** - * Determine the Stripe tax ID type for Switzerland (UID vs VAT). - * - * Source reference: - * - Stripe docs list supported types + example formats (Switzerland section) - * https://docs.stripe.com/billing/customer/tax-ids - * - * @param {string|undefined|null} taxIdValue - * @returns {'ch_uid'|'ch_vat'|null} - */ -export function getSwitzerlandTaxIdType(taxIdValue) { - const normalized = normalizeTaxIdCompact(taxIdValue) - if (!normalized) return null - - const alnum = normalized.replace(/[^A-Z0-9]/g, '') - - // VAT: CHE-123.456.789 MWST - if (/^CHE\d{9}(MWST|TVA|IVA)$/.test(alnum)) return 'ch_vat' - - // UID: CHE-123.456.789 HR - if (/^CHE\d{9}(HR)?$/.test(alnum)) return 'ch_uid' - - return null -} - -/** - * Determine the Stripe tax ID type for the United Kingdom (GB VAT vs EU VAT for NI). - * - * Source reference: - * - Stripe docs list supported types + example formats (UK section) - * https://docs.stripe.com/billing/customer/tax-ids - * - * @param {string|undefined|null} taxIdValue - * @returns {'gb_vat'|'eu_vat'|null} - */ -export function getUkTaxIdType(taxIdValue) { - const normalized = normalizeTaxIdCompact(taxIdValue) - if (!normalized) return null - - // Northern Ireland VAT numbers use XI prefix - if (/^XI[A-Z0-9]+$/.test(normalized)) return 'eu_vat' - - // GB VAT numbers use GB prefix - if (/^GB(\d{9}|\d{12})$/.test(normalized)) return 'gb_vat' - - return null -} - -/** - * Normalize a GB VAT number to the standard format. - * - * @param {string|undefined|null} taxIdValue - * @returns {string|undefined|null} - Normalized GB VAT number if valid, otherwise original value - */ -export function normalisedGBVATNumber(taxIdValue) { - if (!taxIdValue) return taxIdValue - - // Strip spaces and punctuation - let normalized = String(taxIdValue) - .trim() - .toUpperCase() - .replace(/[\s\-.]/g, '') - - // Prepend GB if not already there (but don't prepend if it starts with XI for Northern Ireland) - if (!normalized.startsWith('GB') && !normalized.startsWith('XI')) { - normalized = 'GB' + normalized - } - - // Remove any trailing GB (but not if it's the only GB at the start) - if (normalized.endsWith('GB') && normalized.length > 2) { - normalized = normalized.slice(0, -2) - } - - // Check if it's a valid GB VAT number - const taxIdType = getUkTaxIdType(normalized) - if (taxIdType === 'gb_vat') { - return normalized - } - - // Return original value if not valid - return taxIdValue -} - -/** - * Determine the Stripe tax ID type for Uzbekistan (TIN vs VAT). - * - * Source reference: - * - Stripe docs list supported types + example formats (Uzbekistan section) - * https://docs.stripe.com/billing/customer/tax-ids - * - * @param {string|undefined|null} taxIdValue - * @returns {'uz_tin'|'uz_vat'|null} - */ -export function getUzbekistanTaxIdType(taxIdValue) { - const digits = digitsOnly(taxIdValue) - if (!digits) return null - - // VAT: 12 digits (example: 123456789012) - if (/^\d{12}$/.test(digits)) return 'uz_vat' - - // TIN: 9 digits (example: 123456789) - if (/^\d{9}$/.test(digits)) return 'uz_tin' - - return null -} - -// we are now no longer pre-validating tax IDS based on country-specific formats before sending to Stripe. -// however leaving the regexes and examples here for reference and potential future use -const COUNTRY_TAX_ID_FORMATS = { - // Africa - AO: { taxIdType: 'ao_tin', regex: /^\d{10}$/ }, // Example: 5123456789 - BH: { taxIdType: 'bh_vat', regex: /^\d{15}$/ }, // Example: 123456789012345 - BF: { taxIdType: 'bf_ifu', regex: /^\d{8}[A-Z]$/ }, // Example: 12345678A - BJ: { taxIdType: 'bj_ifu', regex: /^\d{13}$/ }, // Example: 1234567890123 - CM: { taxIdType: 'cm_niu', regex: /^[A-Z]\d{12}[A-Z]$/ }, // Example: M123456789000L - CV: { taxIdType: 'cv_nif', regex: /^\d{9}$/ }, // Example: 213456789 - CD: { taxIdType: 'cd_nif', regex: /^[A-Z]\d{7}[A-Z]$/ }, // Example: A0123456M - EG: { taxIdType: 'eg_tin', regex: /^\d{9}$/ }, // Example: 123456789 - ET: { taxIdType: 'et_tin', regex: /^\d{10}$/ }, // Example: 1234567890 - GN: { taxIdType: 'gn_nif', regex: /^\d{9}$/ }, // Example: 123456789 - KE: { taxIdType: 'ke_pin', regex: /^[A-Z]\d{9}[A-Z]$/ }, // Example: P000111111A - MA: { taxIdType: 'ma_vat', regex: /^\d{8}$/ }, // Example: 12345678 - MR: { taxIdType: 'mr_nif', regex: /^\d{8}$/ }, // Example: 12345678 - NG: { taxIdType: 'ng_tin', regex: /^\d{8,14}$/ }, // Example: 12345678-0001 - SN: { taxIdType: 'sn_ninea', regex: /^\d{8}[A-Z]\d$/ }, // Example: 12345672A2 - TZ: { taxIdType: 'tz_vat', regex: /^\d{8}[A-Z]$/ }, // Example: 12345678A - UG: { taxIdType: 'ug_tin', regex: /^\d{10}$/ }, // Example: 1014751879 - ZA: { taxIdType: 'za_vat', regex: /^\d{10}$/ }, // Example: 4123456789 - ZM: { taxIdType: 'zm_tin', regex: /^\d{10}$/ }, // Example: 1004751879 - ZW: { taxIdType: 'zw_tin', regex: /^\d{10}$/ }, // Example: 1234567890 - - // Americas - AR: { taxIdType: 'ar_cuit', regex: /^\d{11}$/ }, // Example: 12-3456789-01 - BO: { taxIdType: 'bo_tin', regex: /^\d{9}$/ }, // Example: 123456789 - BS: { taxIdType: 'bs_tin', regex: /^\d{9}$/ }, // Example: 123.456.789 - BB: { taxIdType: 'bb_tin', regex: /^\d{13}$/ }, // Example: 1123456789012 - CL: { taxIdType: 'cl_tin', regex: /^\d{7,8}[0-9Kk]$/ }, // Example: 12.345.678-K - CO: { taxIdType: 'co_nit', regex: /^\d{10}$/ }, // Example: 123.456.789-0 - CR: { taxIdType: 'cr_tin', regex: /^\d{10}$/ }, // Example: 1-234-567890 - DO: { taxIdType: 'do_rcn', regex: /^\d{11}$/ }, // Example: 123-4567890-1 - EC: { taxIdType: 'ec_ruc', regex: /^\d{13}$/ }, // Example: 1234567890001 - // Accounts for Companies (12 chars). Individual entrepreneurs use 13 characters (4 letters at the start) - // Note: & is also a valid character in some Mexican company names - // and for companies, that first "alpha" section is derived directly from the legal name so may contain an ampersand. - MX: { taxIdType: 'mx_rfc', regex: /^[A-Z&]{3,4}\d{6}[A-Z0-9]{3}$/ }, // Example: ABC010203AB9 - PE: { taxIdType: 'pe_ruc', regex: /^\d{11}$/ }, // Example: 12345678901 - SR: { taxIdType: 'sr_fin', regex: /^\d{10}$/ }, // Example: 1234567890 - SV: { taxIdType: 'sv_nit', regex: /^\d{14}$/ }, // Example: 1234-567890-123-4 - US: { taxIdType: 'us_ein', regex: /^\d{9}$/ }, // Example: 12-3456789 - UY: { taxIdType: 'uy_ruc', regex: /^\d{12}$/ }, // Example: 123456789012 - VE: { taxIdType: 've_rif', regex: /^[JGVGE]\d{9}$/ }, // Example: A-12345678-9 - - // Asia-Pacific - BD: { taxIdType: 'bd_bin', regex: /^\d{13}$/ }, // Example: 123456789-0123 - // the USCC (China) strictly excludes the letters I, O, S, V, and Z to prevent optical character recognition (OCR) errors. - CN: { taxIdType: 'cn_tin', regex: /^[0-9A-HJ-NP-RTUW-Y]{18}$/ }, // Example: 12350000426600329N - HK: { taxIdType: 'hk_br', regex: /^\d{8}$/ }, // Example: 12345678 - ID: { taxIdType: 'id_npwp', regex: /^\d{15}$/ }, // Example: 012.345.678.9-012.345 - IN: { - taxIdType: 'in_gst', - // The 13th character is usually a number but can be a letter. The 15th character (Check digit) can be a letter or a number. - // The 14th char is reserved by the Indian govt. One day it might be something other than 'Z' but for now it's always 'Z'. - regex: /^\d{2}[A-Z]{5}\d{4}[A-Z][A-Z0-9][Z][A-Z0-9]$/, - }, // Example: 12ABCDE3456FGZH - KH: { taxIdType: 'kh_tin', regex: /^\d{13}$/ }, // Example: 1001-123456789 - KR: { taxIdType: 'kr_brn', regex: /^\d{10}$/ }, // Example: 123-45-67890 - KZ: { taxIdType: 'kz_bin', regex: /^\d{12}$/ }, // Example: 123456789012 - KG: { taxIdType: 'kg_tin', regex: /^\d{14}$/ }, // Example: 12345678901234 - LA: { taxIdType: 'la_tin', regex: /^\d{12}$/ }, // Example: 123456789-000 - NZ: { taxIdType: 'nz_gst', regex: /^\d{9}$/ }, // Example: 123456789 - NP: { taxIdType: 'np_pan', regex: /^\d{9}$/ }, // Example: 123456789 - PH: { taxIdType: 'ph_tin', regex: /^\d{12}$/ }, // Example: 123456789012 - TH: { taxIdType: 'th_vat', regex: /^\d{13}$/ }, // Example: 1234567891234 - TW: { taxIdType: 'tw_vat', regex: /^\d{8}$/ }, // Example: 12345678 - VN: { taxIdType: 'vn_tin', regex: /^\d{10}$/ }, // Example: 1234567890 - - // Europe (non-EU) - AD: { taxIdType: 'ad_nrt', regex: /^[A-Z]\d{6}[A-Z]$/ }, // Example: A-123456-Z - AL: { taxIdType: 'al_tin', regex: /^[A-Z]\d{8}[A-Z]$/ }, // Example: J12345678N - AM: { taxIdType: 'am_tin', regex: /^\d{8}$/ }, // Example: 02538904 - AW: { taxIdType: 'aw_tin', regex: /^\d{8}$/ }, // Example: 12345678 - AZ: { taxIdType: 'az_tin', regex: /^\d{10}$/ }, // Example: 0123456789 - BA: { taxIdType: 'ba_tin', regex: /^\d{12}$/ }, // Example: 123456789012 - BY: { taxIdType: 'by_tin', regex: /^\d{9}$/ }, // Example: 123456789 - // CH: { taxIdType: 'ch_vat', regex: /^CHE\d{9}MWST$/ }, // Example: CHE-123.456.789 MWST - GE: { taxIdType: 'ge_vat', regex: /^\d{9}$/ }, // Example: 123456789 - IS: { taxIdType: 'is_vat', regex: /^\d{6}$/ }, // Example: 123456 - LI: { taxIdType: 'li_uid', regex: /^CHE\d{9}$/ }, // Example: CHE123456789 - MD: { taxIdType: 'md_vat', regex: /^\d{7}$/ }, // Example: 1234567 - ME: { taxIdType: 'me_pib', regex: /^\d{8}$/ }, // Example: 12345678 - MK: { taxIdType: 'mk_vat', regex: /^MK\d{13}$/ }, // Example: MK1234567890123 - NO: { taxIdType: 'no_vat', regex: /^\d{9}MVA$/ }, // Example: 123456789MVA - RS: { taxIdType: 'rs_pib', regex: /^\d{9}$/ }, // Example: 123456789 - RU: { taxIdType: 'ru_inn', regex: /^\d{10}$/ }, // Example: 1234567891 - TR: { taxIdType: 'tr_tin', regex: /^\d{10}$/ }, // Example: 0123456789 - UA: { taxIdType: 'ua_vat', regex: /^\d{9}$/ }, // Example: 123456789 - - // Middle East - AE: { taxIdType: 'ae_trn', regex: /^\d{15}$/ }, // Example: 123456789012345 - IL: { taxIdType: 'il_vat', regex: /^\d{9}$/ }, // Example: 000012345 - OM: { taxIdType: 'om_vat', regex: /^OM\d{10}$/ }, // Example: OM1234567890 - SA: { taxIdType: 'sa_vat', regex: /^\d{15}$/ }, // Example: 123456789012345 - - // Other - EU: { taxIdType: 'eu_oss_vat', regex: /^EU\d+$/ }, // Example: EU123456789 -} - -/** - * Get the Stripe tax ID type for a given country + tax ID value. - * - * Note: for some countries (e.g. Canada) the type depends on the tax ID value format, - * not just the country. - * - * @param {string|undefined|null} country - ISO 3166-1 alpha-2 - * @param {string|undefined|null} taxIdValue - * @param {string|undefined|null} postalCode - used for Canada province inference - * @param {boolean} [preValidateFormat=false] - Optional behavior flag to pre-validate format. - * @returns {{type: string|null, reason: string|null}} - Stripe tax ID type + failure reason (when type is null) - */ -// TODO: this function is naive - we need more than just country to determine tax ID type -// for example canada has multiple types (BN, QST, GST/HST) depending on province (inferred from postal code) -export function getTaxIdType( - country, - taxIdValue, - postalCode, - preValidateFormat = false -) { - if (!country) return { type: null, reason: 'missing country' } - - const upperCountry = String(country).toUpperCase() - const normalizedTaxId = normalizeTaxIdCompact(taxIdValue) - if (!normalizedTaxId) return { type: null, reason: 'missing tax ID value' } - - if (upperCountry === 'EU') { - // European One Stop Shop VAT number for non-Union scheme - const normalized = normalizeTaxIdCompact(taxIdValue) - return /^EU\d+$/.test(normalized) - ? { type: 'eu_oss_vat', reason: null } - : { type: null, reason: 'invalid EU OSS VAT number' } - } - - // EU VAT - if (EU_VAT_COUNTRIES.includes(upperCountry)) { - // If this country has multiple types, we'll handle it below with a dedicated function. - // Otherwise, EU VAT is the only supported type for that country. - const euVatOnlyCountries = new Set([ - 'AT', - 'BE', - 'CY', - 'CZ', - 'DK', - 'EE', - 'FI', - 'FR', - 'GR', - 'IE', - 'IT', - 'LT', - 'LU', - 'LV', - 'MT', - 'NL', - 'PT', - 'SE', - 'SK', - ]) - if (euVatOnlyCountries.has(upperCountry)) { - if (preValidateFormat && !hasEuVatPrefix(upperCountry, taxIdValue)) { - return { - type: null, - reason: `invalid tax ID format for country ${upperCountry}`, - } - } - return { type: 'eu_vat', reason: null } - } - } - - // Multi-type countries - if (upperCountry === 'CA') { - const type = getCanadaTaxIdType(taxIdValue, postalCode) - return type - ? { type, reason: null } - : { type: null, reason: 'unrecognized tax ID format for country CA' } - } - if (upperCountry === 'AU') { - const type = getAustraliaTaxIdType(taxIdValue) - return type - ? { type, reason: null } - : { type: null, reason: 'unrecognized tax ID format for country AU' } - } - if (upperCountry === 'BR') { - const type = getBrazilTaxIdType(taxIdValue) - return type - ? { type, reason: null } - : { type: null, reason: 'unrecognized tax ID format for country BR' } - } - if (upperCountry === 'BG') { - const type = getBulgariaTaxIdType(taxIdValue) - return type - ? { type, reason: null } - : { type: null, reason: 'unrecognized tax ID format for country BG' } - } - if (upperCountry === 'HR') { - const type = getCroatiaTaxIdType(taxIdValue) - return type - ? { type, reason: null } - : { type: null, reason: 'unrecognized tax ID format for country HR' } - } - if (upperCountry === 'DE') { - const type = getGermanyTaxIdType(taxIdValue) - return type - ? { type, reason: null } - : { type: null, reason: 'unrecognized tax ID format for country DE' } - } - if (upperCountry === 'HU') { - const type = getHungaryTaxIdType(taxIdValue) - return type - ? { type, reason: null } - : { type: null, reason: 'unrecognized tax ID format for country HU' } - } - if (upperCountry === 'JP') { - const type = getJapanTaxIdType(taxIdValue) - return type - ? { type, reason: null } - : { type: null, reason: 'unrecognized tax ID format for country JP' } - } - if (upperCountry === 'LI') { - const type = getLiechtensteinTaxIdType(taxIdValue) - return type - ? { type, reason: null } - : { type: null, reason: 'unrecognized tax ID format for country LI' } - } - if (upperCountry === 'MY') { - const type = getMalaysiaTaxIdType(taxIdValue) - return type - ? { type, reason: null } - : { type: null, reason: 'unrecognized tax ID format for country MY' } - } - if (upperCountry === 'NO') { - const type = getNorwayTaxIdType(taxIdValue) - return type - ? { type, reason: null } - : { type: null, reason: 'unrecognized tax ID format for country NO' } - } - if (upperCountry === 'PL') { - const type = getPolandTaxIdType(taxIdValue) - return type - ? { type, reason: null } - : { type: null, reason: 'unrecognized tax ID format for country PL' } - } - if (upperCountry === 'RO') { - const type = getRomaniaTaxIdType(taxIdValue) - return type - ? { type, reason: null } - : { type: null, reason: 'unrecognized tax ID format for country RO' } - } - if (upperCountry === 'RU') { - const type = getRussiaTaxIdType(taxIdValue) - return type - ? { type, reason: null } - : { type: null, reason: 'unrecognized tax ID format for country RU' } - } - if (upperCountry === 'SG') { - const type = getSingaporeTaxIdType(taxIdValue) - return type - ? { type, reason: null } - : { type: null, reason: 'unrecognized tax ID format for country SG' } - } - if (upperCountry === 'SI') { - const type = getSloveniaTaxIdType(taxIdValue) - return type - ? { type, reason: null } - : { type: null, reason: 'unrecognized tax ID format for country SI' } - } - if (upperCountry === 'ES') { - const type = getSpainTaxIdType(taxIdValue) - return type - ? { type, reason: null } - : { type: null, reason: 'unrecognized tax ID format for country ES' } - } - if (upperCountry === 'CH') { - const type = getSwitzerlandTaxIdType(taxIdValue) - return type - ? { type, reason: null } - : { type: null, reason: 'unrecognized tax ID format for country CH' } - } - if (upperCountry === 'GB') { - const type = getUkTaxIdType(taxIdValue) - return type - ? { type, reason: null } - : { type: null, reason: 'unrecognized tax ID format for country GB' } - } - if (upperCountry === 'UZ') { - const type = getUzbekistanTaxIdType(taxIdValue) - return type - ? { type, reason: null } - : { type: null, reason: 'unrecognized tax ID format for country UZ' } - } - - const countryFormat = COUNTRY_TAX_ID_FORMATS[upperCountry] - if (countryFormat) { - if (preValidateFormat) { - const normalized = normalizeTaxIdAlnum(taxIdValue) - if (!countryFormat.regex.test(normalized)) { - return { - type: null, - reason: `invalid tax ID format for country ${upperCountry}`, - } - } - } - return { type: countryFormat.taxIdType, reason: null } - } - - return { type: null, reason: `unsupported country ${upperCountry}` } -} - -/** - * Remove paypal billing agreement id from account for logging purposes - * - * @param {object} account - * @returns {object} sanitized account object - */ -export function sanitizeAccount(account) { - return { - ...account, - billingInfo: sanitizeBillingInfo(account.billingInfo), - } -} - -/** - * Remove paypal billing agreement id from billing info for logging purposes - * - * @param {object} billingInfo - * @returns {object} sanitized billing info object - */ -export function sanitizeBillingInfo(billingInfo) { - const sanitizedBillingInfo = structuredClone(billingInfo) - if ( - sanitizedBillingInfo?.paymentMethod?.object === 'paypal_billing_agreement' - ) { - sanitizedBillingInfo.paymentMethod.billingAgreementId = - 'REDACTED_FOR_LOGGING' - } - return sanitizedBillingInfo -} - -/** - * - * @param {Stripe.PaymentMethod[]} paymentMethods - * @param {string} stripeCustomerId - * @param {object|null} billingInfo - Recurly billing info object - * @returns {Stripe.PaymentMethod} valid payment method - * @throws {Error} if no valid payment method found - */ -export function coalesceOrThrowPaymentMethod( - paymentMethods, - stripeCustomerId, - billingInfo -) { - if (paymentMethods.length === 0) { - throw new Error( - `Stripe customer ${stripeCustomerId} has no usable payment method` - ) - } - - const matchingPaymentMethods = paymentMethods.filter(method => - areStripeAndRecurlyCardDetailsEqual(method, billingInfo?.paymentMethod) - ) - - if (matchingPaymentMethods.length === 0) { - throw new Error( - `Stripe customer ${stripeCustomerId} has no usable payment method` - ) - } - - return matchingPaymentMethods[0] -} - -/** - * Compare Stripe card details with Recurly card details to determine if they likely represent the same card. - * - * Checks last4 and expiry month/year. - * - * @param {Stripe.PaymentMethod} stripePaymentMethod - * @param {object} recurlyPaymentMethod - Recurly billingInfo.paymentMethod object - * @returns {boolean} true if details match, false otherwise - */ -export function areStripeAndRecurlyCardDetailsEqual( - stripePaymentMethod, - recurlyPaymentMethod -) { - if ( - !stripePaymentMethod || - stripePaymentMethod.type !== 'card' || - !stripePaymentMethod.card || - !recurlyPaymentMethod?.lastFour - ) { - return false - } - - return ( - stripePaymentMethod.card.last4 === recurlyPaymentMethod.lastFour && - stripePaymentMethod.card.exp_month === recurlyPaymentMethod.expMonth && - stripePaymentMethod.card.exp_year === recurlyPaymentMethod.expYear - ) -} - -// ============================================================================= -// ADDRESS / STRING COMPARISON HELPERS -// ============================================================================= - -export function normalizeComparableString(value) { - if (value == null) return '' - return String(value).trim() -} - -export function hasAnyAddressValue(address) { - if (!address || typeof address !== 'object') return false - return Object.values(address).some(v => normalizeComparableString(v) !== '') -} - -export function ccEmailsToArray(ccEmails) { - if (ccEmails == null || ccEmails === undefined) return [] - const normalisedEmails = String(ccEmails) - .split(/[\s,;]+/) - .filter(Boolean) - return [...new Set(normalisedEmails)] -} - -export function normalizeTaxIdValue(taxId) { - const value = normalizeComparableString(taxId) - return value.replace(/[.\-_\s:/]/g, '').toLowerCase() -} - -// ============================================================================= -// CUSTOM FIELDS -// ============================================================================= - -export const RECURLY_CUSTOM_FIELD_NAMES = [ - 'channel', - 'Industry', - 'ol_sales_person', - 'MigratedfromFreeAgent', -] - -/** - * Extract Recurly custom fields as Stripe metadata. - * - * Expects an account object with a `customFields` array (Recurly npm SDK format) - * where each element has `name` and `value` properties. - * - * @param {object} account - Recurly account object (npm SDK format) - * @returns {{ metadata: Record, counts: Record }} - */ -export function extractRecurlyCustomFieldMetadata(account) { - const customFields = account?.customFields - if (!Array.isArray(customFields)) { - throw new Error( - 'Unexpected Recurly response: account.customFields is missing or not an array' - ) - } - - /** @type {Record} */ - const metadata = {} - - const counts = { - channel: 0, - Industry: 0, - ol_sales_person: 0, - MigratedfromFreeAgent: 0, - noCustomFields: 0, - } - - if (customFields.length === 0) { - counts.noCustomFields = 1 - return { metadata, counts } - } - - for (const field of customFields) { - const name = field?.name?.trim() - if (!RECURLY_CUSTOM_FIELD_NAMES.includes(name)) continue - const rawValue = field?.value - if (rawValue == null) continue - const value = String(rawValue).trim() - if (!value) continue - metadata[name] = value - counts[name] = 1 - } - return { metadata, counts } -} - -function normalizeComparableAddress(address) { - if (!address || typeof address !== 'object') return null - const norm = { - line1: normalizeComparableString(address.line1), - line2: normalizeComparableString(address.line2), - city: normalizeComparableString(address.city), - state: normalizeComparableString(address.state), - postal_code: normalizeComparableString(address.postal_code), - country: normalizeComparableString(address.country).toUpperCase(), - } - return hasAnyAddressValue(norm) ? norm : null -} - -export function addressesEqual(a, b) { - const na = normalizeComparableAddress(a) - const nb = normalizeComparableAddress(b) - if (!na && !nb) return true - if (!na || !nb) return false - return ( - na.line1 === nb.line1 && - na.line2 === nb.line2 && - na.city === nb.city && - na.state === nb.state && - na.postal_code === nb.postal_code && - na.country === nb.country - ) -} - -// ============================================================================= -// CUSTOMER IDENTITY RESOLUTION -// ============================================================================= - -/** - * Resolve customer name, address, company, and VAT number from Recurly account data. - * - * For most customers, billing info and account info agree (or only one source is set), - * and the standard coalesce logic applies (billing info preferred, account as fallback). - * - * When any field has conflicting values across both sources, `fetchCollectionMethod` - * is called to determine which source wins: - * - * - automatic (web sales): billing info is used for the Stripe customer record. - * - manual (manual billing): account info is used for the Stripe customer record, - * and billing info is returned separately to be copied to the payment method's - * billing_details. - * - * @param {object} account - Recurly account object - * @param {() => Promise} fetchCollectionMethod - Async callback that returns the - * subscription's collection method ('automatic' or 'manual') when a conflict is detected. - * @returns {Promise<{ - * name: string|null, - * address: import('stripe').Stripe.AddressParam|null, - * companyName: string|null, - * vatNumber: string|null, - * collectionMethod: string|null, - * billingInfoForPaymentMethod: object|null - * }>} - */ -export async function resolveCustomerIdentity(account, fetchCollectionMethod) { - const billingName = extractNameFromBillingInfo(account) - const accountName = extractNameFromAccount(account) - const nameConflict = - billingName !== null && accountName !== null && billingName !== accountName - - const billingAddress = normalizeRecurlyAddressToStripe( - account.billingInfo?.address - ) - const accountAddress = normalizeRecurlyAddressToStripe(account?.address) - const addressConflict = - billingAddress !== null && - accountAddress !== null && - !addressesEqual(billingAddress, accountAddress) - - const billingCompany = account.billingInfo?.company?.trim() || null - const accountCompany = account.company?.trim() || null - const companyConflict = - billingCompany !== null && - accountCompany !== null && - billingCompany !== accountCompany - - const billingVat = account.billingInfo?.vatNumber?.trim() || null - const accountVat = account?.vatNumber?.trim() || null - const vatConflict = - billingVat !== null && accountVat !== null && billingVat !== accountVat - - const hasConflict = - nameConflict || addressConflict || companyConflict || vatConflict - - let name, - address, - companyName, - vatNumber, - collectionMethod, - billingInfoForPaymentMethod - - if (!hasConflict) { - name = billingName ?? accountName - address = billingAddress ?? accountAddress - companyName = billingCompany ?? accountCompany - vatNumber = billingVat ?? accountVat - collectionMethod = null - billingInfoForPaymentMethod = null - } else { - collectionMethod = await fetchCollectionMethod() - - if (!collectionMethod) { - throw new Error( - 'Conflict between billing info and account fields, but no subscription found to determine collection method' - ) - } - - if (collectionMethod === 'automatic') { - name = billingName ?? accountName - address = billingAddress ?? accountAddress - companyName = billingCompany ?? accountCompany - vatNumber = billingVat ?? accountVat - billingInfoForPaymentMethod = null - } else if (collectionMethod === 'manual') { - name = accountName ?? billingName - address = accountAddress ?? billingAddress - companyName = accountCompany ?? billingCompany - vatNumber = accountVat ?? billingVat - billingInfoForPaymentMethod = account.billingInfo - } else { - throw new Error(`Unexpected collectionMethod: ${collectionMethod}`) - } - } - - if (vatNumber && address?.country === 'GB') { - vatNumber = normalisedGBVATNumber(vatNumber) - } - - return { - name, - address, - companyName, - vatNumber, - collectionMethod, - billingInfoForPaymentMethod, - } -} - -// ============================================================================= -// ACCOUNT COMPARISON -// ============================================================================= - -/** - * Compare account-level fields between a Recurly account and a Stripe customer. - * - * This encapsulates the drift-detection logic shared by both the finalize script - * and the compare script. - * - * @param {object} options - * @param {object} options.account - Recurly account (npm SDK camelCase format) - * @param {object} options.stripeCustomer - Stripe customer (expanded with tax_ids, default_payment_method) - * @param {string} options.overleafUserId - Overleaf user ID (for metadata.userId comparison) - * @param {() => Promise} options.fetchCollectionMethod - Callback to get subscription collection method - * @param {Array} options.stripePaymentMethods - Pre-fetched Stripe payment methods - * @param {string} options.stripeServiceName - 'stripe-us' or 'stripe-uk' - * @returns {Promise>} - Per-field diffs (empty = no drift) - */ -export async function compareAccountFields({ - account, - stripeCustomer, - overleafUserId, - fetchCollectionMethod, - stripePaymentMethods, - stripeServiceName, -}) { - const { name, address, companyName, vatNumber } = - await resolveCustomerIdentity(account, fetchCollectionMethod) - - const diffs = {} - - // Email - if ( - normalizeComparableString(account.email) !== - normalizeComparableString(stripeCustomer.email) - ) { - diffs.email = { - recurly: account.email || null, - stripe: stripeCustomer.email || null, - } - } - - // Name - if ( - normalizeComparableString(name) !== - normalizeComparableString(stripeCustomer.name) - ) { - diffs.name = { - recurly: name || null, - stripe: stripeCustomer.name || null, - } - } - - // Address - if (address) { - if (!addressesEqual(address, stripeCustomer.address)) { - diffs.address = { - recurly: address, - stripe: stripeCustomer.address || null, - } - } - } - - // Business name (company) - if (companyName) { - if ( - normalizeComparableString(companyName) !== - normalizeComparableString(stripeCustomer.business_name) - ) { - diffs.business_name = { - recurly: companyName, - stripe: stripeCustomer.business_name || null, - } - } - } - - // Metadata - const expectedMetadata = {} - if (account.createdAt) { - expectedMetadata.recurlyCreatedAt = account.createdAt.toISOString() - } - expectedMetadata.recurlyAccountCode = '' - expectedMetadata.userId = overleafUserId - - const { metadata: customFieldMetadata } = - extractRecurlyCustomFieldMetadata(account) - Object.assign(expectedMetadata, customFieldMetadata) - - // Tax ID - let expectedTaxIdType = null - let expectedTaxIdValue = null - if (vatNumber) { - const taxIdTypeResult = getTaxIdType( - address?.country, - vatNumber, - address?.postal_code, - false - ) - if (taxIdTypeResult.type && address?.country) { - expectedTaxIdType = taxIdTypeResult.type - expectedTaxIdValue = vatNumber - } - } - - for (const [key, expectedValue] of Object.entries(expectedMetadata)) { - const stripeValue = stripeCustomer.metadata?.[key] ?? '' - if ( - normalizeComparableString(expectedValue) !== - normalizeComparableString(stripeValue) - ) { - diffs[`metadata.${key}`] = { - recurly: expectedValue, - stripe: stripeValue || null, - } - } - } - - // Tax exempt - const expectedTaxExempt = account.taxExempt ? 'exempt' : 'none' - if (expectedTaxExempt !== (stripeCustomer.tax_exempt || 'none')) { - diffs.tax_exempt = { - recurly: expectedTaxExempt, - stripe: stripeCustomer.tax_exempt || 'none', - } - } - - // CC emails - const expectedCcEmails = ccEmailsToArray(account.ccEmails) - const stripeCcEmails = stripeCustomer.additional_emails?.cc || [] - const recurlyCcSorted = [...expectedCcEmails].sort() - const stripeCcSorted = [...stripeCcEmails].sort() - if (JSON.stringify(recurlyCcSorted) !== JSON.stringify(stripeCcSorted)) { - diffs.cc_emails = { - recurly: expectedCcEmails, - stripe: stripeCcEmails, - } - } - - // Tax ID - if (expectedTaxIdType && expectedTaxIdValue) { - const normalizedExpectedTaxIdValue = normalizeTaxIdValue(expectedTaxIdValue) - const stripePendingTaxId = stripeCustomer.metadata?.taxInfoPending - ? [ - { - type: expectedTaxIdType, - value: stripeCustomer.metadata.taxInfoPending, - }, - ] - : null - const stripeTaxIdsFromStripe = stripeCustomer.tax_ids?.data - const stripeTaxIds = - Array.isArray(stripeTaxIdsFromStripe) && stripeTaxIdsFromStripe.length > 0 - ? stripeTaxIdsFromStripe - : stripePendingTaxId || [] - const matchingTaxId = stripeTaxIds.find( - tid => - tid.type === expectedTaxIdType && - normalizedExpectedTaxIdValue && - normalizeTaxIdValue(tid.value) === normalizedExpectedTaxIdValue - ) - if (!matchingTaxId) { - diffs.tax_id = { - recurly: { type: expectedTaxIdType, value: expectedTaxIdValue }, - stripe: - stripeTaxIds.length > 0 - ? stripeTaxIds.map(t => ({ type: t.type, value: t.value })) - : null, - } - } - } - - // Default payment method - const stripeDefaultPaymentMethod = - stripeCustomer.invoice_settings?.default_payment_method - const isPaypalBillingAgreement = - account.billingInfo?.paymentMethod?.object === 'paypal_billing_agreement' - - let expectedPaymentMethod = null - if ( - isPaypalBillingAgreement && - !['US', 'CA'].includes(address?.country) && - stripeServiceName !== 'stripe-us' - ) { - expectedPaymentMethod = { type: 'paypal' } - } - - if ( - !isPaypalBillingAgreement && - stripePaymentMethods.length > 0 && - account.billingInfo && - account.billingInfo.paymentMethod - ) { - expectedPaymentMethod = account.billingInfo.paymentMethod - } - - const stripePaymentMethodCreatedAt = stripeDefaultPaymentMethod?.created - const recurlyPaymentMethodUpdatedAt = - (account.billingInfo?.updatedAt?.getTime() || 0) / 1000 - const recurlyPaymentMethodIsNewer = - stripePaymentMethodCreatedAt < recurlyPaymentMethodUpdatedAt - const paymentMethodIsMissing = - (!stripeDefaultPaymentMethod && expectedPaymentMethod) || - (stripeDefaultPaymentMethod && !expectedPaymentMethod) - - const bothArePaypal = - expectedPaymentMethod?.type === 'paypal' && - stripeDefaultPaymentMethod?.type === 'paypal' - - if (bothArePaypal) { - // Both are PayPal — the payment method itself matches, but flag if - // Recurly billing info was updated after the Stripe payment method was - // created. - if (recurlyPaymentMethodIsNewer) { - diffs.default_payment_method = { - recurly: { - type: 'paypal', - updatedAt: account.billingInfo?.updatedAt?.toISOString() || null, - }, - stripe: { - type: 'paypal', - created: stripeDefaultPaymentMethod?.created - ? new Date(stripeDefaultPaymentMethod.created * 1000).toISOString() - : null, - }, - bothPaypal: true, - } - } - } else if ( - paymentMethodIsMissing || - (!areStripeAndRecurlyCardDetailsEqual( - stripeDefaultPaymentMethod, - expectedPaymentMethod - ) && - recurlyPaymentMethodIsNewer) - ) { - diffs.default_payment_method = { - recurly: { - type: expectedPaymentMethod?.type || 'card', - last4: expectedPaymentMethod?.lastFour || null, - expiry: expectedPaymentMethod?.lastFour - ? `${expectedPaymentMethod.expMonth}/${expectedPaymentMethod.expYear}` - : null, - updatedAt: account.billingInfo?.updatedAt?.toISOString() || null, - }, - stripe: { - type: stripeDefaultPaymentMethod?.type || null, - last4: stripeDefaultPaymentMethod?.card - ? stripeDefaultPaymentMethod.card.last4 - : null, - expiry: stripeDefaultPaymentMethod?.card - ? `${stripeDefaultPaymentMethod.card.exp_month}/${stripeDefaultPaymentMethod.card.exp_year}` - : null, - created: stripeDefaultPaymentMethod?.created - ? new Date(stripeDefaultPaymentMethod.created * 1000).toISOString() - : null, - }, - } - } - - return diffs -} diff --git a/services/web/scripts/helpers/migrate_recurly_customers_to_stripe.helpers.node.test.mjs b/services/web/scripts/helpers/migrate_recurly_customers_to_stripe.helpers.node.test.mjs deleted file mode 100644 index 85ff0c4643..0000000000 --- a/services/web/scripts/helpers/migrate_recurly_customers_to_stripe.helpers.node.test.mjs +++ /dev/null @@ -1,1148 +0,0 @@ -/* eslint-disable @overleaf/require-script-runner */ -import test from 'node:test' -import assert from 'node:assert/strict' - -/* - * This test can be run from the services/web directory with: - * - * node --test scripts/helpers/migrate_recurly_customers_to_stripe.helpers.node.test.mjs - * - * It can be deleted once the Recurly to Stripe migration is complete. - */ - -import { - coalesceOrEqualOrThrow, - coalesceOrThrowVATNumber, - extractNameFromAccount, - getCanadaTaxIdType, - getAustraliaTaxIdType, - getBrazilTaxIdType, - getBulgariaTaxIdType, - getCroatiaTaxIdType, - getGermanyTaxIdType, - getHungaryTaxIdType, - getJapanTaxIdType, - getLiechtensteinTaxIdType, - getMalaysiaTaxIdType, - getNorwayTaxIdType, - getPolandTaxIdType, - getRomaniaTaxIdType, - getRussiaTaxIdType, - getSingaporeTaxIdType, - getSloveniaTaxIdType, - getSpainTaxIdType, - getSwitzerlandTaxIdType, - getUkTaxIdType, - getUzbekistanTaxIdType, - getTaxIdType, - coalesceOrThrowPaymentMethod, - normalisedGBVATNumber, - normalizeComparableString, - hasAnyAddressValue, - ccEmailsToArray, - normalizeTaxIdValue, - extractRecurlyCustomFieldMetadata, - addressesEqual, - resolveCustomerIdentity, - compareAccountFields, - areStripeAndRecurlyCardDetailsEqual, -} from './migrate_recurly_customers_to_stripe.helpers.mjs' - -test('extractNameFromAccount returns normalized full name when first and last are present', () => { - const account = { - firstName: ' Alice ', - lastName: ' Example ', - } - - assert.equal(extractNameFromAccount(account), 'Alice Example') -}) - -test('extractNameFromAccount falls back to firstName when lastName is missing', () => { - const account = { - firstName: 'Alice Example', - lastName: '', - } - - assert.equal(extractNameFromAccount(account), 'Alice Example') -}) - -test('coalesceOrEqualOrThrow returns primary when set', () => { - assert.equal(coalesceOrEqualOrThrow('a', undefined, 'field'), 'a') -}) - -test('coalesceOrEqualOrThrow returns fallback when primary is unset', () => { - assert.equal(coalesceOrEqualOrThrow(undefined, 'b', 'field'), 'b') -}) - -test('coalesceOrEqualOrThrow returns value when both are set and equal', () => { - assert.equal(coalesceOrEqualOrThrow('same', 'same', 'field'), 'same') -}) - -test('coalesceOrEqualOrThrow throws when both are set but differ', () => { - assert.throws( - () => coalesceOrEqualOrThrow('a', 'b', 'field'), - /Primary and fallback values are both set but differ/ - ) -}) - -test('coalesceOrThrowVATNumber returns billingInfo VAT when set', () => { - const account = { - vatNumber: '', - billingInfo: { vatNumber: 'BILL456' }, - } - assert.equal(coalesceOrThrowVATNumber(account), 'BILL456') -}) - -test('coalesceOrThrowVATNumber returns account VAT when billingInfo VAT unset', () => { - const account = { - vatNumber: 'ACCT123', - billingInfo: { vatNumber: '' }, - } - assert.equal(coalesceOrThrowVATNumber(account), 'ACCT123') -}) - -test('coalesceOrThrowVATNumber returns null when neither is set', () => { - assert.equal(coalesceOrThrowVATNumber({}), null) - assert.equal( - coalesceOrThrowVATNumber({ vatNumber: '', billingInfo: { vatNumber: '' } }), - null - ) -}) - -test('coalesceOrThrowVATNumber treats trimmed values as equal', () => { - const account = { - vatNumber: ' GB123 ', - billingInfo: { vatNumber: 'GB123' }, - } - assert.equal(coalesceOrThrowVATNumber(account), 'GB123') -}) - -test('coalesceOrThrowVATNumber throws when both are set but differ', () => { - const account = { - vatNumber: 'GB123', - billingInfo: { vatNumber: 'DE999' }, - } - assert.throws( - () => coalesceOrThrowVATNumber(account), - /Field vatNumber: Primary and fallback values are both set but differ/ - ) -}) - -test('getCanadaTaxIdType returns ca_gst_hst for GST/HST format', () => { - assert.equal(getCanadaTaxIdType('123456789RT0002', null), 'ca_gst_hst') - assert.equal(getCanadaTaxIdType(' 123456789rt0002 ', null), 'ca_gst_hst') -}) - -test('getCanadaTaxIdType returns ca_qst for Quebec QST format', () => { - assert.equal(getCanadaTaxIdType('1234567890TQ1234', null), 'ca_qst') - assert.equal(getCanadaTaxIdType('1234567890tq1234', null), 'ca_qst') -}) - -test('getCanadaTaxIdType returns PST types for documented provincial formats', () => { - assert.equal(getCanadaTaxIdType('PST-1234-5678', null), 'ca_pst_bc') - assert.equal(getCanadaTaxIdType('123456-7', 'R3C 4T3'), 'ca_pst_mb') - assert.equal(getCanadaTaxIdType('1234567', 'S7K 3J8'), 'ca_pst_sk') -}) - -test('getCanadaTaxIdType returns ca_bn for 9-digit BN format', () => { - // assert.equal(getCanadaTaxIdType('123456789', null), 'ca_bn') - assert.equal(getCanadaTaxIdType('123456789', null), null) // TODO: improve function get definitive ca_bn vs ca_gst_hst -}) - -test('getCanadaTaxIdType returns null when format is unknown/ambiguous', () => { - assert.equal(getCanadaTaxIdType('', null), null) - assert.equal(getCanadaTaxIdType(null, null), null) - assert.equal(getCanadaTaxIdType('RT0002', null), null) - assert.equal(getCanadaTaxIdType('PST12345678', null), null) -}) - -test('getAustraliaTaxIdType distinguishes ABN vs ARN', () => { - assert.equal(getAustraliaTaxIdType('12345678912'), 'au_abn') - assert.equal(getAustraliaTaxIdType('123456789123'), 'au_arn') -}) - -test('getBrazilTaxIdType distinguishes CNPJ vs CPF', () => { - assert.equal(getBrazilTaxIdType('01.234.456/5432-10'), 'br_cnpj') - assert.equal(getBrazilTaxIdType('123.456.789-87'), 'br_cpf') -}) - -test('getBulgariaTaxIdType distinguishes EU VAT vs UIC', () => { - assert.equal(getBulgariaTaxIdType('BG0123456789'), 'eu_vat') - assert.equal(getBulgariaTaxIdType('123456789'), 'bg_uic') -}) - -test('getCroatiaTaxIdType distinguishes EU VAT vs OIB', () => { - assert.equal(getCroatiaTaxIdType('HR12345678912'), 'eu_vat') - assert.equal(getCroatiaTaxIdType('12345678901'), 'hr_oib') -}) - -test('getGermanyTaxIdType distinguishes EU VAT vs Steuer Nummer', () => { - assert.equal(getGermanyTaxIdType('DE123456789'), 'eu_vat') - assert.equal(getGermanyTaxIdType('1234567890'), 'de_stn') -}) - -test('getHungaryTaxIdType distinguishes EU VAT vs HU tax number', () => { - assert.equal(getHungaryTaxIdType('HU12345678'), 'eu_vat') - assert.equal(getHungaryTaxIdType('12345678-1-23'), 'hu_tin') -}) - -test('getJapanTaxIdType distinguishes TRN, CN, RN', () => { - assert.equal(getJapanTaxIdType('T1234567891234'), 'jp_trn') - assert.equal(getJapanTaxIdType('1234567891234'), 'jp_cn') - assert.equal(getJapanTaxIdType('12345'), 'jp_rn') -}) - -test('getLiechtensteinTaxIdType distinguishes UID vs VAT', () => { - assert.equal(getLiechtensteinTaxIdType('CHE123456789'), 'li_uid') - assert.equal(getLiechtensteinTaxIdType('12345'), 'li_vat') -}) - -test('getMalaysiaTaxIdType distinguishes FRP, ITN, SST', () => { - assert.equal(getMalaysiaTaxIdType('12345678'), 'my_frp') - assert.equal(getMalaysiaTaxIdType('C 1234567890'), 'my_itn') - assert.equal(getMalaysiaTaxIdType('A12-3456-78912345'), 'my_sst') -}) - -test('getNorwayTaxIdType distinguishes VAT vs VOEC', () => { - assert.equal(getNorwayTaxIdType('123456789MVA'), 'no_vat') - assert.equal(getNorwayTaxIdType('1234567'), 'no_voec') -}) - -test('getPolandTaxIdType distinguishes EU VAT vs NIP', () => { - assert.equal(getPolandTaxIdType('PL1234567890'), 'eu_vat') - assert.equal(getPolandTaxIdType('1234567890'), 'pl_nip') -}) - -test('getRomaniaTaxIdType distinguishes EU VAT vs TIN', () => { - assert.equal(getRomaniaTaxIdType('RO1234567891'), 'eu_vat') - assert.equal(getRomaniaTaxIdType('1234567890123'), 'ro_tin') -}) - -test('getRussiaTaxIdType distinguishes INN vs KPP', () => { - assert.equal(getRussiaTaxIdType('1234567891'), 'ru_inn') - assert.equal(getRussiaTaxIdType('123456789'), 'ru_kpp') -}) - -test('getSingaporeTaxIdType distinguishes GST vs UEN', () => { - assert.equal(getSingaporeTaxIdType('M12345678X'), 'sg_gst') - assert.equal(getSingaporeTaxIdType('123456789F'), 'sg_uen') -}) - -test('getSloveniaTaxIdType distinguishes EU VAT vs TIN', () => { - assert.equal(getSloveniaTaxIdType('SI12345678'), 'eu_vat') - assert.equal(getSloveniaTaxIdType('12345678'), 'si_tin') -}) - -test('getSpainTaxIdType distinguishes EU VAT vs CIF', () => { - assert.equal(getSpainTaxIdType('ESA1234567Z'), 'eu_vat') - assert.equal(getSpainTaxIdType('A12345678'), 'es_cif') -}) - -test('getSwitzerlandTaxIdType distinguishes UID vs VAT', () => { - assert.equal(getSwitzerlandTaxIdType('CHE-123.456.789 HR'), 'ch_uid') - assert.equal(getSwitzerlandTaxIdType('CHE-123.456.789 MWST'), 'ch_vat') -}) - -test('getUkTaxIdType distinguishes GB VAT vs EU VAT (NI)', () => { - assert.equal(getUkTaxIdType('GB123456789'), 'gb_vat') - assert.equal(getUkTaxIdType('XI123456789'), 'eu_vat') -}) - -test('getUzbekistanTaxIdType distinguishes TIN vs VAT', () => { - assert.equal(getUzbekistanTaxIdType('123456789'), 'uz_tin') - assert.equal(getUzbekistanTaxIdType('123456789012'), 'uz_vat') -}) - -test('getTaxIdType handles EU OSS VAT and EU VAT defaults', () => { - assert.equal(getTaxIdType('EU', 'EU123456789').type, 'eu_oss_vat') - assert.equal(getTaxIdType('AT', 'ATU12345678').type, 'eu_vat') -}) - -test('getTaxIdType includes failure reason', () => { - const missingCountry = getTaxIdType(null, '12345', null) - assert.equal(missingCountry.type, null) - assert.equal(missingCountry.reason, 'missing country') - - const invalidEuOss = getTaxIdType('EU', 'INVALID', null) - assert.equal(invalidEuOss.type, null) - assert.equal(invalidEuOss.reason, 'invalid EU OSS VAT number') -}) - -test('getTaxIdType pre-validation is opt-in', () => { - // Default behavior: no country-format pre-validation - const noPreValidation = getTaxIdType('US', 'NOT_A_VALID_EIN', null) - assert.equal(noPreValidation.type, 'us_ein') - assert.equal(noPreValidation.reason, null) - - // Optional behavior: enable country-format pre-validation - const withPreValidation = getTaxIdType('US', 'NOT_A_VALID_EIN', null, true) - assert.equal(withPreValidation.type, null) - assert.equal(withPreValidation.reason, 'invalid tax ID format for country US') -}) - -test('getTaxIdType pre-validates EU VAT-only countries when enabled', () => { - const invalidWithPreValidation = getTaxIdType('AT', '12345678', null, true) - - assert.equal(invalidWithPreValidation.type, null) - assert.equal( - invalidWithPreValidation.reason, - 'invalid tax ID format for country AT' - ) - - const validWithPreValidation = getTaxIdType('AT', 'ATU12345678', null, true) - - assert.equal(validWithPreValidation.type, 'eu_vat') - assert.equal(validWithPreValidation.reason, null) -}) - -test('coalesceOrThrowPaymentMethod throws when payment methods array is empty', () => { - assert.throws( - () => coalesceOrThrowPaymentMethod([], 'cus_123', {}), - /Stripe customer cus_123 has no usable payment method/ - ) -}) - -test('coalesceOrThrowPaymentMethod throws when no payment methods match billing info', () => { - const paymentMethods = [ - { - id: 'pm_1', - card: { last4: '1234', exp_month: 12, exp_year: 2030 }, - }, - ] - const billingInfo = { - paymentMethod: { lastFour: '5678', expMonth: 12, expYear: 2030 }, - } - assert.throws( - () => coalesceOrThrowPaymentMethod(paymentMethods, 'cus_123', billingInfo), - /Stripe customer cus_123 has no usable payment method/ - ) -}) - -test('coalesceOrThrowPaymentMethod returns matching payment method', () => { - const paymentMethod = { - id: 'pm_match', - type: 'card', - card: { last4: '1234', exp_month: 12, exp_year: 2030 }, - } - const paymentMethods = [paymentMethod] - const billingInfo = { - paymentMethod: { lastFour: '1234', expMonth: 12, expYear: 2030 }, - } - const result = coalesceOrThrowPaymentMethod( - paymentMethods, - 'cus_123', - billingInfo - ) - assert.equal(result, paymentMethod) -}) - -test('coalesceOrThrowPaymentMethod matches first of multiple matching methods', () => { - const paymentMethod1 = { - id: 'pm_1', - type: 'card', - card: { last4: '1234', exp_month: 12, exp_year: 2030 }, - } - const paymentMethod2 = { - id: 'pm_2', - type: 'card', - card: { last4: '1234', exp_month: 12, exp_year: 2030 }, - } - const paymentMethods = [paymentMethod1, paymentMethod2] - const billingInfo = { - paymentMethod: { lastFour: '1234', expMonth: 12, expYear: 2030 }, - } - const result = coalesceOrThrowPaymentMethod( - paymentMethods, - 'cus_123', - billingInfo - ) - assert.equal(result, paymentMethod1) -}) - -test('coalesceOrThrowPaymentMethod filters out non-matching methods', () => { - const matchingMethod = { - id: 'pm_match', - type: 'card', - card: { last4: '1234', exp_month: 12, exp_year: 2030 }, - } - const nonMatchingMethod = { - id: 'pm_no_match', - type: 'card', - card: { last4: '5678', exp_month: 12, exp_year: 2030 }, - } - const paymentMethods = [nonMatchingMethod, matchingMethod] - const billingInfo = { - paymentMethod: { lastFour: '1234', expMonth: 12, expYear: 2030 }, - } - const result = coalesceOrThrowPaymentMethod( - paymentMethods, - 'cus_123', - billingInfo - ) - assert.equal(result, matchingMethod) -}) - -test('coalesceOrThrowPaymentMethod handles null billingInfo', () => { - const paymentMethods = [ - { - id: 'pm_1', - card: { last4: '1234', exp_month: 12, exp_year: 2030 }, - }, - ] - assert.throws( - () => coalesceOrThrowPaymentMethod(paymentMethods, 'cus_123', null), - /Stripe customer cus_123 has no usable payment method/ - ) -}) - -test('coalesceOrThrowPaymentMethod handles missing paymentMethod in billingInfo', () => { - const paymentMethods = [ - { - id: 'pm_1', - card: { last4: '1234', exp_month: 12, exp_year: 2030 }, - }, - ] - const billingInfo = {} - assert.throws( - () => coalesceOrThrowPaymentMethod(paymentMethods, 'cus_123', billingInfo), - /Stripe customer cus_123 has no usable payment method/ - ) -}) - -test('coalesceOrThrowPaymentMethod handles missing card in payment method', () => { - const paymentMethods = [ - { id: 'pm_1' }, // no card property - { - id: 'pm_2', - type: 'card', - card: { last4: '1234', exp_month: 12, exp_year: 2030 }, - }, - ] - const billingInfo = { - paymentMethod: { lastFour: '1234', expMonth: 12, expYear: 2030 }, - } - const result = coalesceOrThrowPaymentMethod( - paymentMethods, - 'cus_123', - billingInfo - ) - assert.equal(result.id, 'pm_2') -}) - -test('normalisedGBVATNumber strips spaces and punctuation', () => { - assert.equal(normalisedGBVATNumber('123 456 789'), 'GB123456789') - assert.equal(normalisedGBVATNumber('123-456-789'), 'GB123456789') - assert.equal(normalisedGBVATNumber('123.456.789'), 'GB123456789') - assert.equal(normalisedGBVATNumber(' 123 456 789 '), 'GB123456789') -}) - -test('normalisedGBVATNumber prepends GB if not present', () => { - assert.equal(normalisedGBVATNumber('123456789'), 'GB123456789') - assert.equal(normalisedGBVATNumber('123456789012'), 'GB123456789012') -}) - -test('normalisedGBVATNumber does not prepend GB if already present', () => { - assert.equal(normalisedGBVATNumber('GB123456789'), 'GB123456789') - assert.equal(normalisedGBVATNumber('GB123456789012'), 'GB123456789012') - assert.equal(normalisedGBVATNumber('gb123456789'), 'GB123456789') -}) - -test('normalisedGBVATNumber removes trailing GB', () => { - assert.equal(normalisedGBVATNumber('123456789GB'), 'GB123456789') - assert.equal(normalisedGBVATNumber('GB123456789GB'), 'GB123456789') -}) - -test('normalisedGBVATNumber returns original if invalid after normalization', () => { - // Invalid length (8 digits instead of 9 or 12) - const invalid = '12345678' - assert.equal(normalisedGBVATNumber(invalid), invalid) - - // Invalid length (10 digits instead of 9 or 12) - const invalid2 = '1234567890' - assert.equal(normalisedGBVATNumber(invalid2), invalid2) - - // Invalid format with letters - const invalid3 = 'ABC123456' - assert.equal(normalisedGBVATNumber(invalid3), invalid3) -}) - -test('normalisedGBVATNumber handles null and undefined', () => { - assert.equal(normalisedGBVATNumber(null), null) - assert.equal(normalisedGBVATNumber(undefined), undefined) - assert.equal(normalisedGBVATNumber(''), '') -}) - -test('normalisedGBVATNumber handles valid 9-digit GB VAT numbers', () => { - assert.equal(normalisedGBVATNumber('123456789'), 'GB123456789') - assert.equal(normalisedGBVATNumber('GB123456789'), 'GB123456789') - assert.equal(normalisedGBVATNumber(' GB 123 456 789 '), 'GB123456789') -}) - -test('normalisedGBVATNumber handles valid 12-digit GB VAT numbers', () => { - assert.equal(normalisedGBVATNumber('123456789012'), 'GB123456789012') - assert.equal(normalisedGBVATNumber('GB123456789012'), 'GB123456789012') - assert.equal(normalisedGBVATNumber('GB 123 456 789 012'), 'GB123456789012') -}) - -test('normalisedGBVATNumber preserves Northern Ireland XI numbers', () => { - // XI numbers should not be modified to GB - assert.equal(normalisedGBVATNumber('XI123456789'), 'XI123456789') -}) - -test('normalizeComparableString returns empty string for null/undefined', () => { - assert.equal(normalizeComparableString(null), '') - assert.equal(normalizeComparableString(undefined), '') -}) - -test('normalizeComparableString trims whitespace', () => { - assert.equal(normalizeComparableString(' hello '), 'hello') - assert.equal(normalizeComparableString(' '), '') -}) - -test('normalizeComparableString converts non-strings', () => { - assert.equal(normalizeComparableString(42), '42') - assert.equal(normalizeComparableString(0), '0') -}) - -test('hasAnyAddressValue returns false for null/undefined/non-object', () => { - assert.equal(hasAnyAddressValue(null), false) - assert.equal(hasAnyAddressValue(undefined), false) - assert.equal(hasAnyAddressValue('string'), false) -}) - -test('hasAnyAddressValue returns false for all-empty address', () => { - assert.equal(hasAnyAddressValue({ line1: '', line2: '', city: '' }), false) - assert.equal(hasAnyAddressValue({ line1: ' ', city: null }), false) -}) - -test('hasAnyAddressValue returns true when any field is non-empty', () => { - assert.equal(hasAnyAddressValue({ line1: '123 Main St' }), true) - assert.equal(hasAnyAddressValue({ country: 'US' }), true) -}) - -test('ccEmailsToArray returns empty array for null/undefined', () => { - assert.deepEqual(ccEmailsToArray(null), []) - assert.deepEqual(ccEmailsToArray(undefined), []) -}) - -test('ccEmailsToArray splits on commas, semicolons, and whitespace', () => { - assert.deepEqual(ccEmailsToArray('a@b.com,c@d.com'), ['a@b.com', 'c@d.com']) - assert.deepEqual(ccEmailsToArray('a@b.com;c@d.com'), ['a@b.com', 'c@d.com']) - assert.deepEqual(ccEmailsToArray('a@b.com c@d.com'), ['a@b.com', 'c@d.com']) -}) - -test('ccEmailsToArray deduplicates', () => { - assert.deepEqual(ccEmailsToArray('a@b.com,a@b.com,c@d.com'), [ - 'a@b.com', - 'c@d.com', - ]) -}) - -test('ccEmailsToArray handles empty string', () => { - assert.deepEqual(ccEmailsToArray(''), []) -}) - -test('normalizeTaxIdValue strips punctuation and lowercases', () => { - assert.equal(normalizeTaxIdValue('GB.123-456_789'), 'gb123456789') - assert.equal(normalizeTaxIdValue(' DE:123/456 '), 'de123456') -}) - -test('normalizeTaxIdValue handles null/undefined', () => { - assert.equal(normalizeTaxIdValue(null), '') - assert.equal(normalizeTaxIdValue(undefined), '') -}) - -test('extractRecurlyCustomFieldMetadata extracts known fields', () => { - const account = { - customFields: [ - { name: 'channel', value: 'web' }, - { name: 'Industry', value: 'Education' }, - { name: 'unknown_field', value: 'ignored' }, - ], - } - const { metadata, counts } = extractRecurlyCustomFieldMetadata(account) - assert.deepEqual(metadata, { channel: 'web', Industry: 'Education' }) - assert.equal(counts.channel, 1) - assert.equal(counts.Industry, 1) - assert.equal(counts.ol_sales_person, 0) -}) - -test('extractRecurlyCustomFieldMetadata returns empty for no custom fields', () => { - const { metadata, counts } = extractRecurlyCustomFieldMetadata({ - customFields: [], - }) - assert.deepEqual(metadata, {}) - assert.equal(counts.noCustomFields, 1) -}) - -test('extractRecurlyCustomFieldMetadata throws for missing customFields', () => { - assert.throws( - () => extractRecurlyCustomFieldMetadata({}), - /account.customFields is missing or not an array/ - ) -}) - -test('extractRecurlyCustomFieldMetadata skips null/empty values', () => { - const account = { - customFields: [ - { name: 'channel', value: null }, - { name: 'Industry', value: '' }, - { name: 'ol_sales_person', value: ' ' }, - ], - } - const { metadata } = extractRecurlyCustomFieldMetadata(account) - assert.deepEqual(metadata, {}) -}) - -test('addressesEqual returns true for two null addresses', () => { - assert.equal(addressesEqual(null, null), true) -}) - -test('addressesEqual returns false when one is null', () => { - assert.equal( - addressesEqual({ line1: '123 Main St', country: 'US' }, null), - false - ) - assert.equal( - addressesEqual(null, { line1: '123 Main St', country: 'US' }), - false - ) -}) - -test('addressesEqual returns true for matching addresses', () => { - const a = { - line1: '123 Main St', - line2: '', - city: 'London', - state: '', - postal_code: 'SW1A 1AA', - country: 'GB', - } - const b = { - line1: '123 Main St', - line2: '', - city: 'London', - state: '', - postal_code: 'SW1A 1AA', - country: 'gb', - } - assert.equal(addressesEqual(a, b), true) -}) - -test('addressesEqual returns false for differing addresses', () => { - const a = { - line1: '123 Main St', - city: 'London', - country: 'GB', - } - const b = { - line1: '456 High St', - city: 'London', - country: 'GB', - } - assert.equal(addressesEqual(a, b), false) -}) - -test('addressesEqual normalizes whitespace', () => { - const a = { line1: ' 123 Main St ', country: ' GB ' } - const b = { line1: '123 Main St', country: 'GB' } - assert.equal(addressesEqual(a, b), true) -}) - -test('areStripeAndRecurlyCardDetailsEqual returns false for null inputs', () => { - assert.equal(areStripeAndRecurlyCardDetailsEqual(null, null), false) - assert.equal( - areStripeAndRecurlyCardDetailsEqual(null, { lastFour: '1234' }), - false - ) - assert.equal( - areStripeAndRecurlyCardDetailsEqual( - { type: 'card', card: { last4: '1234', exp_month: 12, exp_year: 2030 } }, - null - ), - false - ) -}) - -test('areStripeAndRecurlyCardDetailsEqual returns false for non-card type', () => { - assert.equal( - areStripeAndRecurlyCardDetailsEqual( - { type: 'paypal' }, - { lastFour: '1234' } - ), - false - ) -}) - -test('areStripeAndRecurlyCardDetailsEqual returns true for matching cards', () => { - assert.equal( - areStripeAndRecurlyCardDetailsEqual( - { type: 'card', card: { last4: '4242', exp_month: 6, exp_year: 2025 } }, - { lastFour: '4242', expMonth: 6, expYear: 2025 } - ), - true - ) -}) - -test('areStripeAndRecurlyCardDetailsEqual returns false for mismatched last4', () => { - assert.equal( - areStripeAndRecurlyCardDetailsEqual( - { type: 'card', card: { last4: '4242', exp_month: 6, exp_year: 2025 } }, - { lastFour: '1111', expMonth: 6, expYear: 2025 } - ), - false - ) -}) - -test('resolveCustomerIdentity uses billing info when no conflict', async () => { - const account = { - billingInfo: { - firstName: 'Bill', - lastName: 'User', - address: { street1: '123 Bill St', country: 'US' }, - company: 'Bill Corp', - vatNumber: 'US123', - }, - firstName: 'Account', - lastName: 'User', - address: { street1: '456 Acct St', country: 'US' }, - company: 'Acct Corp', - vatNumber: 'US123', - } - // No conflict on vatNumber (same), but name differs -> conflict - const result = await resolveCustomerIdentity(account, async () => 'automatic') - assert.equal(result.name, 'Bill User') -}) - -test('resolveCustomerIdentity uses account info for manual collection', async () => { - const account = { - billingInfo: { - firstName: 'Bill', - lastName: 'User', - address: { street1: '123 Bill St', country: 'US' }, - }, - firstName: 'Account', - lastName: 'User', - address: { street1: '456 Acct St', country: 'US' }, - } - const result = await resolveCustomerIdentity(account, async () => 'manual') - assert.equal(result.name, 'Account User') - assert.notEqual(result.billingInfoForPaymentMethod, null) -}) - -test('resolveCustomerIdentity returns billing info preferred when no conflict', async () => { - const account = { - billingInfo: { - firstName: 'Same', - lastName: 'Name', - vatNumber: 'GB123456789', - }, - firstName: 'Same', - lastName: 'Name', - vatNumber: 'GB123456789', - } - const result = await resolveCustomerIdentity(account, async () => { - throw new Error('should not be called') - }) - assert.equal(result.name, 'Same Name') - assert.equal(result.collectionMethod, null) -}) - -test('resolveCustomerIdentity does not call fetchCollectionMethod when no conflict', async () => { - let called = false - const account = { - billingInfo: { firstName: 'Only', lastName: 'Billing' }, - } - await resolveCustomerIdentity(account, async () => { - called = true - return 'automatic' - }) - assert.equal(called, false) -}) - -test('resolveCustomerIdentity throws when conflict but no collection method', async () => { - const account = { - billingInfo: { - firstName: 'Bill', - lastName: 'Name', - }, - firstName: 'Acct', - lastName: 'Name', - } - await assert.rejects( - () => resolveCustomerIdentity(account, async () => null), - /no subscription found to determine collection method/ - ) -}) - -test('resolveCustomerIdentity normalises GB VAT', async () => { - const account = { - billingInfo: { - firstName: 'Test', - lastName: 'User', - address: { country: 'GB' }, - vatNumber: '123456789', - }, - } - const result = await resolveCustomerIdentity(account, async () => 'automatic') - assert.equal(result.vatNumber, 'GB123456789') -}) - -test('compareAccountFields returns empty diffs when everything matches', async () => { - const account = { - email: 'user@example.com', - firstName: 'Test', - lastName: 'User', - customFields: [], - billingInfo: { - firstName: 'Test', - lastName: 'User', - }, - } - const stripeCustomer = { - email: 'user@example.com', - name: 'Test User', - metadata: { - recurlyAccountCode: '', - userId: 'user123', - taxInfoPending: '', - }, - tax_exempt: 'none', - } - - const diffs = await compareAccountFields({ - account, - stripeCustomer, - overleafUserId: 'user123', - fetchCollectionMethod: async () => null, - stripePaymentMethods: [], - stripeServiceName: 'stripe-uk', - }) - - assert.deepEqual(diffs, {}) -}) - -test('compareAccountFields detects email drift', async () => { - const account = { - email: 'new@example.com', - customFields: [], - billingInfo: {}, - } - const stripeCustomer = { - email: 'old@example.com', - metadata: { - recurlyAccountCode: '', - userId: 'user123', - taxInfoPending: '', - }, - tax_exempt: 'none', - } - - const diffs = await compareAccountFields({ - account, - stripeCustomer, - overleafUserId: 'user123', - fetchCollectionMethod: async () => null, - stripePaymentMethods: [], - stripeServiceName: 'stripe-uk', - }) - - assert.ok(diffs.email) - assert.equal(diffs.email.recurly, 'new@example.com') - assert.equal(diffs.email.stripe, 'old@example.com') -}) - -test('compareAccountFields detects name drift', async () => { - const account = { - email: 'user@example.com', - customFields: [], - billingInfo: { - firstName: 'New', - lastName: 'Name', - }, - } - const stripeCustomer = { - email: 'user@example.com', - name: 'Old Name', - metadata: { - recurlyAccountCode: '', - userId: 'user123', - taxInfoPending: '', - }, - tax_exempt: 'none', - } - - const diffs = await compareAccountFields({ - account, - stripeCustomer, - overleafUserId: 'user123', - fetchCollectionMethod: async () => null, - stripePaymentMethods: [], - stripeServiceName: 'stripe-uk', - }) - - assert.ok(diffs.name) - assert.equal(diffs.name.recurly, 'New Name') - assert.equal(diffs.name.stripe, 'Old Name') -}) - -test('compareAccountFields detects tax ID drift', async () => { - const account = { - email: 'user@example.com', - customFields: [], - billingInfo: { - firstName: 'Test', - lastName: 'User', - address: { country: 'DE' }, - vatNumber: 'DE123456789', - }, - } - const stripeCustomer = { - email: 'user@example.com', - name: 'Test User', - address: { country: 'DE' }, - metadata: { - recurlyAccountCode: '', - userId: 'user123', - taxInfoPending: '', - }, - tax_exempt: 'none', - tax_ids: { data: [] }, - } - - const diffs = await compareAccountFields({ - account, - stripeCustomer, - overleafUserId: 'user123', - fetchCollectionMethod: async () => null, - stripePaymentMethods: [], - stripeServiceName: 'stripe-uk', - }) - - assert.ok(diffs.tax_id) - assert.equal(diffs.tax_id.recurly.type, 'eu_vat') - assert.equal(diffs.tax_id.recurly.value, 'DE123456789') -}) - -test('compareAccountFields does not report tax ID diff when values match after normalization', async () => { - const account = { - email: 'user@example.com', - customFields: [], - billingInfo: { - firstName: 'Test', - lastName: 'User', - address: { country: 'DE' }, - vatNumber: 'DE-123.456.789', - }, - } - const stripeCustomer = { - email: 'user@example.com', - name: 'Test User', - address: { country: 'DE' }, - metadata: { - recurlyAccountCode: '', - userId: 'user123', - taxInfoPending: '', - }, - tax_exempt: 'none', - tax_ids: { - data: [{ type: 'eu_vat', value: 'DE123456789' }], - }, - } - - const diffs = await compareAccountFields({ - account, - stripeCustomer, - overleafUserId: 'user123', - fetchCollectionMethod: async () => null, - stripePaymentMethods: [], - stripeServiceName: 'stripe-uk', - }) - - assert.equal(diffs.tax_id, undefined) -}) - -test('compareAccountFields handles no payment methods on either side without crashing', async () => { - const account = { - email: 'user@example.com', - customFields: [], - billingInfo: {}, - } - const stripeCustomer = { - email: 'user@example.com', - metadata: { - recurlyAccountCode: '', - userId: 'user123', - taxInfoPending: '', - }, - tax_exempt: 'none', - invoice_settings: { default_payment_method: null }, - } - - // This should not throw (regression test for null payment method crash) - const diffs = await compareAccountFields({ - account, - stripeCustomer, - overleafUserId: 'user123', - fetchCollectionMethod: async () => null, - stripePaymentMethods: [], - stripeServiceName: 'stripe-uk', - }) - - assert.equal(diffs.default_payment_method, undefined) -}) - -test('compareAccountFields detects metadata drift', async () => { - const account = { - email: 'user@example.com', - customFields: [{ name: 'channel', value: 'web' }], - billingInfo: {}, - } - const stripeCustomer = { - email: 'user@example.com', - metadata: { - recurlyAccountCode: '', - userId: 'user123', - taxInfoPending: '', - channel: 'api', - }, - tax_exempt: 'none', - } - - const diffs = await compareAccountFields({ - account, - stripeCustomer, - overleafUserId: 'user123', - fetchCollectionMethod: async () => null, - stripePaymentMethods: [], - stripeServiceName: 'stripe-uk', - }) - - assert.ok(diffs['metadata.channel']) - assert.equal(diffs['metadata.channel'].recurly, 'web') - assert.equal(diffs['metadata.channel'].stripe, 'api') -}) - -test('compareAccountFields detects tax_exempt drift', async () => { - const account = { - email: 'user@example.com', - customFields: [], - billingInfo: {}, - taxExempt: true, - } - const stripeCustomer = { - email: 'user@example.com', - metadata: { - recurlyAccountCode: '', - userId: 'user123', - taxInfoPending: '', - }, - tax_exempt: 'none', - } - - const diffs = await compareAccountFields({ - account, - stripeCustomer, - overleafUserId: 'user123', - fetchCollectionMethod: async () => null, - stripePaymentMethods: [], - stripeServiceName: 'stripe-uk', - }) - - assert.ok(diffs.tax_exempt) - assert.equal(diffs.tax_exempt.recurly, 'exempt') - assert.equal(diffs.tax_exempt.stripe, 'none') -}) - -test('compareAccountFields reports bothPaypal diff when Recurly billingInfo.updatedAt is newer', async () => { - const recurlyUpdatedAt = new Date('2025-06-01T12:00:00Z') - const stripeCreatedEpoch = new Date('2025-05-01T12:00:00Z').getTime() / 1000 - - const account = { - email: 'user@example.com', - customFields: [], - billingInfo: { - updatedAt: recurlyUpdatedAt, - paymentMethod: { object: 'paypal_billing_agreement' }, - }, - address: { country: 'GB' }, - } - const stripeCustomer = { - email: 'user@example.com', - metadata: { - recurlyAccountCode: '', - userId: 'user123', - taxInfoPending: '', - }, - tax_exempt: 'none', - invoice_settings: { - default_payment_method: { - type: 'paypal', - created: stripeCreatedEpoch, - }, - }, - } - - const diffs = await compareAccountFields({ - account, - stripeCustomer, - overleafUserId: 'user123', - fetchCollectionMethod: async () => null, - stripePaymentMethods: [], - stripeServiceName: 'stripe-uk', - }) - - assert.ok(diffs.default_payment_method) - assert.equal(diffs.default_payment_method.bothPaypal, true) - assert.equal(diffs.default_payment_method.recurly.type, 'paypal') - assert.equal( - diffs.default_payment_method.recurly.updatedAt, - recurlyUpdatedAt.toISOString() - ) - assert.equal(diffs.default_payment_method.stripe.type, 'paypal') - assert.equal( - diffs.default_payment_method.stripe.created, - new Date(stripeCreatedEpoch * 1000).toISOString() - ) -}) - -test('compareAccountFields reports no diff when both are PayPal and Recurly is not newer', async () => { - const recurlyUpdatedAt = new Date('2025-04-01T12:00:00Z') - const stripeCreatedEpoch = new Date('2025-05-01T12:00:00Z').getTime() / 1000 - - const account = { - email: 'user@example.com', - customFields: [], - billingInfo: { - updatedAt: recurlyUpdatedAt, - paymentMethod: { object: 'paypal_billing_agreement' }, - }, - address: { country: 'GB' }, - } - const stripeCustomer = { - email: 'user@example.com', - metadata: { - recurlyAccountCode: '', - userId: 'user123', - taxInfoPending: '', - }, - tax_exempt: 'none', - invoice_settings: { - default_payment_method: { - type: 'paypal', - created: stripeCreatedEpoch, - }, - }, - } - - const diffs = await compareAccountFields({ - account, - stripeCustomer, - overleafUserId: 'user123', - fetchCollectionMethod: async () => null, - stripePaymentMethods: [], - stripeServiceName: 'stripe-uk', - }) - - assert.equal(diffs.default_payment_method, undefined) -}) diff --git a/services/web/scripts/insert_feature_flag_alpha_phase_0.mjs b/services/web/scripts/insert_feature_flag_alpha_phase_0.mjs deleted file mode 100644 index 101d01a8c3..0000000000 --- a/services/web/scripts/insert_feature_flag_alpha_phase_0.mjs +++ /dev/null @@ -1,129 +0,0 @@ -import { scriptRunner } from './lib/ScriptRunner.mjs' -import { db } from '../app/src/infrastructure/mongodb.mjs' -import minimist from 'minimist' - -const argv = minimist(process.argv.slice(2)) -const commit = argv.commit !== undefined -const splitTestName = argv._[0] - -if (!splitTestName) { - console.error( - 'Usage: node scripts/insert_feature_flag_alpha_phase_0.mjs [--commit]' - ) - console.error( - '\nInserts a new version 1 (alpha phase, 0% rollout, active) and renumbers existing versions.' - ) - console.error('Without --commit, runs in dry-run mode and shows a diff.') - process.exit(1) -} - -if (!commit) { - console.log('DOING DRY RUN. TO SAVE CHANGES PASS --commit') -} - -const RED = '\x1b[31m' -const GREEN = '\x1b[32m' -const RESET = '\x1b[0m' - -function formatDiff(before, after) { - const aLines = before.split('\n') - const bLines = after.split('\n') - const m = aLines.length - const n = bLines.length - - const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0)) - for (let i = 1; i <= m; i++) { - for (let j = 1; j <= n; j++) { - if (aLines[i - 1] === bLines[j - 1]) { - dp[i][j] = dp[i - 1][j - 1] + 1 - } else { - dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]) - } - } - } - - const result = [] - let i = m - let j = n - while (i > 0 || j > 0) { - if (i > 0 && j > 0 && aLines[i - 1] === bLines[j - 1]) { - result.unshift(` ${aLines[i - 1]}`) - i-- - j-- - } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) { - result.unshift(`${GREEN}+ ${bLines[j - 1]}${RESET}`) - j-- - } else { - result.unshift(`${RED}- ${aLines[i - 1]}${RESET}`) - i-- - } - } - - return result.join('\n') -} - -async function main() { - const splitTest = await db.splittests.findOne({ name: splitTestName }) - - if (!splitTest) { - throw new Error(`Split test '${splitTestName}' not found`) - } - - if (!splitTest.versions || splitTest.versions.length === 0) { - throw new Error(`Split test '${splitTestName}' has no versions`) - } - - const firstVersion = splitTest.versions[0] - - const newVersion = { - ...firstVersion, - versionNumber: 1, - phase: 'alpha', - active: true, - variants: firstVersion.variants.map(v => ({ - ...v, - rolloutPercent: 0, - rolloutStripes: [], - })), - createdAt: new Date(firstVersion.createdAt.getTime() - 1000), - comment: `Inserted alpha phase 0% rollout version (based on v${firstVersion.versionNumber})`, - } - - const updatedVersions = [ - newVersion, - ...splitTest.versions.map(v => ({ - ...v, - versionNumber: v.versionNumber + 1, - })), - ] - - const beforeStr = JSON.stringify(splitTest.versions, null, 2) - const afterStr = JSON.stringify(updatedVersions, null, 2) - - console.log(`\nSplit test: ${splitTestName}`) - console.log(`Current versions: ${splitTest.versions.length}`) - console.log(`After: ${updatedVersions.length} versions`) - console.log('\n--- versions diff ---') - console.log(formatDiff(beforeStr, afterStr)) - console.log('--- end diff ---\n') - - if (commit) { - await db.splittests.updateOne( - { name: splitTestName }, - { $set: { versions: updatedVersions } } - ) - console.log( - `Successfully inserted alpha phase 0% version for '${splitTestName}'` - ) - } else { - console.log('Dry run complete. Pass --commit to apply changes.') - } -} - -try { - await scriptRunner(main) - process.exit(0) -} catch (error) { - console.error(error) - process.exit(1) -} diff --git a/services/web/scripts/inst_table.mjs b/services/web/scripts/inst_table.mjs deleted file mode 100644 index 566de55213..0000000000 --- a/services/web/scripts/inst_table.mjs +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Creates the HTML for the institution in the institution table on /for/universities - */ - -const name = process.argv[2] -const href = process.argv[3] -const image = process.argv[4] - -function create() { - if (!name) { - return console.log('Error: Institution name is required') - } - const eventLabel = name.replace(/ /g, '-').replace(/\(|\)/g, '') - if (!href) { - return console.log('Error: Institution portal href is required') - } - let result = `
    ` - result += `\n
    ` - - if (image) { - result += `\n ` - } - - result += `\n
    ` - result += `\n
    -

    - ${name} -

    ` - result += `\n
    ` - result += `\n
    - VIEW -
    ` - result += '\n
    ' - - console.log(result) -} - -create() diff --git a/services/web/scripts/investigate_esm.mjs b/services/web/scripts/investigate_esm.mjs deleted file mode 100644 index 9f31240e91..0000000000 --- a/services/web/scripts/investigate_esm.mjs +++ /dev/null @@ -1,147 +0,0 @@ -/* eslint-disable */ -import { extractImports, findJSAndImports } from './esm-check-migration.mjs' -import path from 'node:path' -import fs from 'node:fs' - -const imports = await findJSAndImports( - ['app', 'modules'].map(dir => path.resolve(dir)) -) - -const entryPoint = fs.existsSync('app.js') ? 'app.js' : 'app.mjs' -imports.set(path.resolve(entryPoint), extractImports(entryPoint)) - -const moduleImports = new Map() -imports.forEach((deps, module) => { - for (const dep of deps) { - if (!moduleImports.has(dep)) { - moduleImports.set(dep, new Set()) - } - moduleImports.set(dep, moduleImports.get(dep).add(module)) - } -}) - -const soloImports = new Map() -moduleImports.forEach((importedBy, module) => { - if (importedBy.size === 1) { - soloImports.set(module, [...importedBy][0]) - } -}) - -console.log(soloImports) - -for (const [module, importedBy] of soloImports) { - if (!moduleImports.has(importedBy)) { - console.log( - `${module} is only imported by ${importedBy}, which has no other imports` - ) - } - if (soloImports.has(importedBy)) { - console.log( - `${module} is only imported by ${importedBy}, which is only imported by ${soloImports.get(importedBy)}` - ) - } - const chains = findDependencyChainsToTarget(imports, module) - const conversionsToMake = chains.reduce((conversions, chain) => { - chain.forEach(dep => { - conversions.add(dep) - }) - return conversions - }, new Set()) - - if (conversionsToMake.length < 10) { - console.log( - `To convert ${module}, would need to convert: ${[...conversionsToMake].length}` - ) - } -} - -// --- Circular dependency detection --- -function findCircularDependencies(importsMap) { - const cycles = [] - const visited = new Set() - const stack = [] - - function dfs(file, pathStack) { - if (pathStack.includes(file)) { - // Cycle detected - const cycleStart = pathStack.indexOf(file) - cycles.push(pathStack.slice(cycleStart).concat(file)) - return - } - if (!importsMap.has(file)) return - pathStack.push(file) - for (const imp of importsMap.get(file)) { - const resolvedImp = path.resolve(imp) - dfs(resolvedImp, pathStack) - } - pathStack.pop() - } - - for (const file of importsMap.keys()) { - dfs(file, []) - } - return cycles -} - -const cycles = findCircularDependencies(imports) -if (cycles.length > 0) { - console.log('Circular dependencies found:') - for (const cycle of cycles) { - console.log(' ' + cycle.join(' -> ')) - } -} else { - console.log('No circular dependencies detected.') -} - -// --- Find all chains of dependencies to a target file --- -function findDependencyChainsToTarget(importsMap, targetPath) { - const chains = [] - const resolvedTarget = path.resolve(targetPath) - - function dfs(current, pathStack) { - if (pathStack.includes(current)) return // avoid cycles - pathStack.push(current) - if (current === resolvedTarget) { - chains.push([...pathStack]) - pathStack.pop() - return - } - if (!importsMap.has(current)) { - pathStack.pop() - return - } - for (const imp of importsMap.get(current)) { - const resolvedImp = path.resolve(imp) - dfs(resolvedImp, pathStack) - } - pathStack.pop() - } - - for (const file of importsMap.keys()) { - if (file === resolvedTarget) continue // skip self - dfs(file, []) - } - return chains -} - -// Example usage: set your target file path here -const targetFile = - '/Users/arumble/Documents/Projects/internal/services/web/app/src/Features/Analytics/AnalyticsManager.js' // <-- change to your target -const chains = findDependencyChainsToTarget(imports, targetFile) -if (chains.length > 0) { - console.log(`Dependency chains leading to ${targetFile}:`) - for (const chain of chains) { - console.log(' ' + chain.join(' -> ')) - } -} else { - console.log(`No dependency chains found leading to ${targetFile}.`) -} - -const conversionsToMake = chains.reduce((conversions, chain) => { - chain.forEach(dep => { - conversions.add(dep) - }) - return conversions -}, new Set()) - -console.log([...conversionsToMake].join(' ')) diff --git a/services/web/scripts/ip_matcher_ranges.mjs b/services/web/scripts/ip_matcher_ranges.mjs deleted file mode 100644 index 66a170761a..0000000000 --- a/services/web/scripts/ip_matcher_ranges.mjs +++ /dev/null @@ -1,80 +0,0 @@ -#!/usr/bin/env node - -// @ts-check - -import minimist from 'minimist' -import { fileURLToPath } from 'node:url' - -/** - * Converts an integer to its corresponding IPv4 address string representation - * - * @param {number} int - * @returns {string} - */ -const intToIp = int => - [ - (int >>> 24) & 0xff, - (int >>> 16) & 0xff, - (int >>> 8) & 0xff, - int & 0xff, - ].join('.') - -/** - * Convert CIDR to IP range - * - * @param {string} cidr - * @returns {{min: string, max: string}} - */ -const cidrToRange = cidr => { - const [ip, prefixLength] = cidr.split('/') - const prefix = parseInt(prefixLength) - - // Convert IP to 32-bit integer - const ipParts = ip.split('.').map(part => parseInt(part)) - const ipInt = - (ipParts[0] << 24) + (ipParts[1] << 16) + (ipParts[2] << 8) + ipParts[3] - - // Calculate network mask - const mask = (0xffffffff << (32 - prefix)) >>> 0 - - // Calculate network and broadcast addresses - const network = (ipInt & mask) >>> 0 - const broadcast = (network | (0xffffffff >>> prefix)) >>> 0 - - return { - min: intToIp(network), - max: intToIp(broadcast), - } -} - -/** - * Converts an array of CIDR ranges into a single string representation. - * Each CIDR range is converted into its corresponding minimum and maximum IP range, - * formatted as "min..max". All resultant ranges are joined by a comma. - * - * @param {string[]} cidrRanges - An array of CIDR range strings to be converted. - * @returns {string} A string representation of the converted ranges where each - * range is formatted as "min..max" and joined by commas. - */ -export const convertCidrRanges = cidrRanges => - cidrRanges - .map(cidr => { - const range = cidrToRange(cidr) - return `${range.min}..${range.max}` - }) - .join(',') - -// Only run CLI if this file is executed directly -if (fileURLToPath(import.meta.url) === process.argv[1]) { - const argv = minimist(process.argv.slice(2)) - - if (argv._.length === 0) { - console.log('Usage: node scripts/ip_matcher_ranges.mjs [cidr2] ...') - console.log( - 'Example: node scripts/ip_matcher_ranges.mjs 192.168.1.0/24 10.0.0.0/8' - ) - process.exit(1) - } - - console.log(convertCidrRanges(argv._)) -} diff --git a/services/web/scripts/learn/checkSanitize/README.md b/services/web/scripts/learn/checkSanitize/README.md deleted file mode 100644 index dd8e8f80d3..0000000000 --- a/services/web/scripts/learn/checkSanitize/README.md +++ /dev/null @@ -1,32 +0,0 @@ -# Usage - -``` -node scripts/learn/checkSanitize/index.mjs https://LEARN_WIKI -``` - -## Bulk export - -There is a bulk export for media wiki pages, but it produces different -html escaping compared to the regular parse API we use in web. - -The bulk export does not escape all the placeholder HTML-like elements, -like ``. - -## Example output - -Here is how a missing tag gets flagged: - -``` ---- -page : MediaWiki markup for the Overleaf support team -title : MediaWiki markup for the Overleaf support team -match : false -toText : false -text : "Overleaf\n \n Kb/TITLE_SLUGhttps://www.overleaf.com/learn/how-to/TITLE_SLUG\n \n \n Kb/TITLE_SLUG<nowiki>https://www.overleaf.com/learn/how-to/</nowiki>TITLE_SLUG\n " -textToText : " \n \n \n \n MediaWiki page\n Maps to on Overleaf\n \n Kb/TITLE_SLUGhttps://www.overleaf.com/learn/how-to/TITLE_SLUG\n " -sanitizedToText: " \n \n \n \n MediaWiki page\n Maps to on Overleaf\n \n Kb/TITLE_SLUGhttps://www.overleaf.com/learn/how-to/TITLE" -``` - -Note the hidden/escaped `` element. -In addition to the side-by-side comparison of HTML you will see a plain-text diff. diff --git a/services/web/scripts/learn/checkSanitize/checkSanitizeOptions.mjs b/services/web/scripts/learn/checkSanitize/checkSanitizeOptions.mjs deleted file mode 100644 index a702a4846e..0000000000 --- a/services/web/scripts/learn/checkSanitize/checkSanitizeOptions.mjs +++ /dev/null @@ -1,118 +0,0 @@ -import crypto from 'node:crypto' -import fs from 'node:fs' -import Path from 'node:path' -import cheerio from 'cheerio' -// checkSanitizeOptions is only used in dev env -// eslint-disable-next-line import/no-extraneous-dependencies -import * as prettier from 'prettier' -import sanitizeHtml from 'sanitize-html' -import { sanitizeOptions } from '../../../modules/learn/app/src/sanitizeOptions.mjs' -import { fileURLToPath } from 'node:url' - -const __dirname = Path.dirname(fileURLToPath(import.meta.url)) -const EXTRACT_STYLE = process.env.EXTRACT_STYLES === 'true' -const OMIT_STYLE = process.env.OMIT_STYLE !== 'false' -const DUMP_CSS_IN = Path.join( - Path.dirname(Path.dirname(Path.dirname(__dirname))), - 'data', - 'dumpFolder' -) - -function hash(blob) { - return crypto.createHash('sha1').update(blob).digest('hex') -} - -function normalize(blob, title) { - // styles are dropped in web and kept in wiki pages for previewing there. - blob = blob.replace(/

    - This can be any HTML passed to the component. For example, - paragraphs, headers, code samples,{' '} - links, etc are all supported. -