23 Commits
1.11.0 ... main

Author SHA1 Message Date
MartinBraquet
5b6c30b987 Debounce search filter updates to improve input handling and performance 2026-04-04 16:27:41 +02:00
MartinBraquet
8da9bd8883 Add validateProfileFields for LLM profile field normalization and filtering 2026-04-04 16:10:17 +02:00
MartinBraquet
bdbce67423 Add type="button" to theme toggle for accessibility compliance 2026-04-04 14:37:55 +02:00
MartinBraquet
e2cdfc01cd Remove unnecessary console logs and update Playwright reporter configuration 2026-04-04 14:34:50 +02:00
Okechi Jones-Williams
d2c9d12b39 test(e2e): add auth, settings, social and organization page coverage
* Added Database checks to the onboarding flow

* Added compatibility page setup
Added more compatibility questions

* Finished up the onboarding flow suite
Added compatibility question tests and verifications
Updated tests to cover Keywords and Headline changes recently made
Updated tests to cover all of the big5 personality traits

* .

* Fix: Merge conflict

* .

* Fix: Added fix for None discriptive error issue #36
Updated signUp.spec.ts to use new fixture
Updated Account information variable names
Deleted "deleteUserFixture.ts" as it was incorporated into the "base.ts" file

* Linting and Prettier

* Minor cleaning

* Organizing helper func

* Added Google account to the Onboarding flow

* .

* Added account cleanup for google accounts

* Started work on Sign-in tests
Updated seedDatabase.ts to throw an error if the user already exists, to also add display names and usernames so they seedUser func acts like a normal basic user
Some organising of the google auth code

* Linting and Prettier

* Added checks to the deleteUser func to check if the accout exists
Added account deletion checks

* Linting and Prettier

* Added POM's for social and organisation page
Updated settings POM

* Formatting update, fixed homePage locator for signin

* .

* .

* .

* Coderabbitai fix's

* Fix

* Improve test utilities and stabilize onboarding flow tests

* Changes requested

* Seperated deletion tests from onboarding

* Update `.coderabbit.yaml` with improved internationalization guidance and formatting adjustments

* Clean up `.vscode/settings.json` and add it to `.gitignore`

* Add Playwright E2E test guidelines to `.coderabbit.yaml`

* Standardize and improve formatting in `TESTING.md` for better readability and consistency.

* Refactor onboarding flow tests and related utilities; improve formatting and remove redundant tests.

---------

Co-authored-by: MartinBraquet <martin.braquet@gmail.com>
2026-04-04 14:21:40 +02:00
MartinBraquet
09736cd49b Display toast notifications for push messages with navigation fixes 2026-04-03 18:58:36 +02:00
MartinBraquet
f16bef97dc Update styles in SkipLink component to use bg-canvas-100 instead of bg-primary-500 2026-04-03 18:56:32 +02:00
MartinBraquet
92d4222f96 Add debug key 2026-04-03 18:28:19 +02:00
MartinBraquet
008110b015 Refactor Sentry tags for Android app info 2026-04-03 17:25:26 +02:00
MartinBraquet
29ace2d2e5 Release 2026-04-03 13:54:45 +02:00
MartinBraquet
07f927d738 Merge remote-tracking branch 'origin/main' 2026-04-03 13:53:56 +02:00
MartinBraquet
993117ba72 Integrate Sentry for Android app version and build tracking 2026-04-03 13:53:43 +02:00
Martin Braquet
c8801a0235 Enhance CodeRabbit configuration settings
Updated CodeRabbit configuration with new settings for reviews, chat auto-reply, and pre-merge checks.
2026-04-02 19:09:13 +02:00
Martin Braquet
93cd105871 Add .coderabbitignore to exclude specific files 2026-04-02 19:02:54 +02:00
Martin Braquet
4f98d99dd9 Update .coderabbit.yaml configuration
Removed auto-generated PR summary settings and path filters.
2026-04-02 19:02:40 +02:00
Martin Braquet
b46d39d9b7 Enable automatic reviews in CodeRabbit configuration 2026-04-02 18:53:34 +02:00
Martin Braquet
5ea095662b Add CodeRabbit configuration for PR reviews
This configuration file sets up CodeRabbit's behavior for PR summaries and reviews, including instructions for summarizing changes, review profiles, and file exclusions.
2026-04-02 18:53:00 +02:00
MartinBraquet
2400d50247 Add www.compassmeet.com to allowed remotePatterns in next.config.ts 2026-04-02 15:50:30 +02:00
MartinBraquet
8ffd69ff15 Bump Android version code to 81 2026-04-02 15:48:52 +02:00
MartinBraquet
0b721ec7b9 Update alt text in PhotosModal and add WEB_URL clarification comment 2026-04-02 15:48:26 +02:00
MartinBraquet
2019c835a0 Release 2026-04-02 15:21:21 +02:00
MartinBraquet
ff23a8c1bc Wrap useProfile with ProfileProvider and refactor to use React Context 2026-04-02 15:00:48 +02:00
MartinBraquet
df775e9aa3 Improve error handling in run by preserving additional error details 2026-04-02 14:07:02 +02:00
50 changed files with 1522 additions and 274 deletions

120
.coderabbit.yaml Normal file
View File

@@ -0,0 +1,120 @@
# Enables IDE autocompletion for this config file
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
# Language for CodeRabbit's review comments
language: en
# Enable experimental features (currently not using any specific early_access features)
early_access: true
chat:
# CodeRabbit will automatically respond to @coderabbitai mentions in PR comments
auto_reply: true
reviews:
auto_review:
# Automatically trigger reviews when PRs are opened or updated
enabled: true
# Skip auto-review if PR title contains these keywords
ignore_title_keywords:
- "WIP"
# Don't auto-review draft PRs
drafts: false
# Only auto-review PRs targeting these branches
base_branches:
- main
- develop
# Include a high-level summary at the start of each review
high_level_summary: true
# Generate sequence diagrams for complex code flows
sequence_diagrams: true
# Don't include poems in reviews (fun feature, but keeping it professional)
poem: false
# Show review completion status
review_status: true
# Keep the walkthrough section expanded by default
collapse_walkthrough: false
# Include summary of all changed files
changed_files_summary: true
# Don't automatically request changes on the PR (just leave comments)
request_changes_workflow: false
# Pre-merge checks to enforce before merging PRs
pre_merge_checks:
description:
# Validate that PR has a proper description
mode: warning # Options: off, warning, error
docstrings:
# Disable docstring coverage checks (let's assume we don't need them)
mode: off
# Exclude these paths from reviews (build artifacts and dependencies)
path_filters:
- "!**/node_modules/**" # npm dependencies
- "!**/android/**" # Native Android build files
- "!**/ios/**" # Native iOS build files
- "!**/.expo/**" # Expo build cache
- "!**/.expo-shared/**" # Expo shared config
- "!**/dist/**" # Build output
# Custom review instructions for specific file patterns
path_instructions:
# TypeScript/JavaScript files - main app code
- path: "**/*.{ts,tsx,js,jsx}"
instructions: |
General practices:
- Summarize the changes clearly.
- Format the summary with bullet points.
- Highlight any potential breaking changes for users.
- We use early returns to avoid deep nesting.
- Ensure all public functions have docstrings.
- Flag any hardcoded strings; they should be in the constants file.
- Check for edge cases like null values or empty arrays.
- Suggest performance optimizations where appropriate.
Mobile best practices:
- Proper use of hooks (useRouter, useFonts, useAssets)
- Accessibility: touch targets min 44x44, screen reader support
- Safe area handling and platform-specific code (iOS vs Android)
- Memory leaks in useEffect and event listeners
Performance:
- Use FlatList/SectionList for lists (never ScrollView with .map)
- React.memo, useMemo, useCallback where appropriate
TypeScript:
- Avoid 'any', use explicit types
- Prefer 'import type' for type imports
Security:
- No exposed API keys or sensitive data
- Use expo-secure-store for sensitive storage
- Validate deep linking configurations
Internationalization:
- User-visible strings should be externalized to JSON resource files in common/messages via
```
const t = useT()
const message = t('key', 'english string')
```
- path: "tests/e2e/**/*.ts"
instructions: |
Playwright E2E test guidelines for this repo:
- Page objects live in `tests/e2e/web/pages/`. Each class wraps one page/route, holds only `private readonly` Locators, and exposes action methods.
- All tests must use the `app` fixture (type `App`) from `tests/e2e/web/fixtures/base.ts`. Never instantiate page objects directly in a test.
- Cross-page flows (actions spanning multiple pages) belong as methods on the `App` class, not as standalone helper functions.
- Action methods in page objects must assert `expect(locator).toBeVisible()` before interacting.
- Never use `page.waitForTimeout()`. Use Playwright's built-in auto-waiting or `waitForURL` / `waitForSelector`.
- No hardcoded credentials in spec files; use `SPEC_CONFIG.ts` or account fixtures.
- Test account cleanup must be done in fixture teardown (after `await use(...)`), not in `afterEach` hooks.
- File and class names must use PascalCase (e.g., `CompatibilityPage.ts` / `class CompatibilityPage`).
- No DB or Firebase calls inside page object classes; those belong in `tests/e2e/utils/`.
- Flag any new page object not yet registered in `App`.

5
.coderabbitignore Normal file
View File

@@ -0,0 +1,5 @@
**/*.md
package-lock.json
yarn.lock
dist/**
test/mocks/**

2
.gitignore vendored
View File

@@ -102,3 +102,5 @@ test-results
**/coverage **/coverage
*my-release-key.keystore *my-release-key.keystore
.vscode/settings.json

View File

@@ -11,8 +11,8 @@ android {
applicationId "com.compassconnections.app" applicationId "com.compassconnections.app"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 79 versionCode 84
versionName "1.16.0" versionName "1.17.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View File

@@ -238,7 +238,16 @@ public class MainActivity extends BridgeActivity implements ModifiedMainActivity
checkForUpdates(); checkForUpdates();
Uri data = getIntent().getData(); Uri data = getIntent().getData();
if (data != null) pendingDeepLink = data.toString(); if (data != null) {
pendingDeepLink = data.toString();
} else {
// Check for notification endpoint when app is opened from cold start via notification click
String endpoint = getIntent().getStringExtra("endpoint");
if (endpoint != null) {
Log.i("CompassApp", "onCreate found endpoint from notification: " + endpoint);
pendingDeepLink = endpoint;
}
}
} }
private void handleDeepLink(String url) { private void handleDeepLink(String url) {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@compass/api", "name": "@compass/api",
"version": "1.30.3", "version": "1.31.0",
"private": true, "private": true,
"description": "Backend API endpoints", "description": "Backend API endpoints",
"main": "src/serve.ts", "main": "src/serve.ts",

View File

@@ -43,6 +43,144 @@ function getCacheKey(content: string): string {
return hash.digest('hex') return hash.digest('hex')
} }
async function validateProfileFields(
llmProfile: Partial<ProfileWithoutUser>,
validChoices: Record<string, string[]>,
): Promise<Partial<ProfileWithoutUser>> {
const result: Partial<Record<keyof ProfileWithoutUser, any>> = {...llmProfile}
const toArray: (keyof ProfileWithoutUser)[] = [
'diet',
'ethnicity',
'interests',
'causes',
'work',
'languages',
'religion',
'political_beliefs',
'pref_gender',
'pref_relation_styles',
'pref_romantic_styles',
'relationship_status',
'keywords',
]
for (const key of toArray) {
if (result[key] !== undefined) {
if (!Array.isArray(result[key])) {
result[key] = [String(result[key])]
} else {
result[key] = result[key].map(String)
}
// Filter out invalid values
if (validChoices[key]) {
result[key] = result[key].filter((v: string) => validChoices[key].includes(v))
if (result[key].length === 0) {
result[key] = undefined
}
}
}
}
const toString: (keyof ProfileWithoutUser)[] = [
'gender',
'education_level',
'mbti',
'psychedelics',
'cannabis',
'psychedelics_intention',
'cannabis_intention',
'psychedelics_pref',
'cannabis_pref',
'headline',
'city',
'country',
'raised_in_city',
'raised_in_country',
'university',
'company',
'occupation_title',
'religious_beliefs',
'political_details',
]
for (const key of toString) {
if (result[key] !== undefined) {
if (Array.isArray(result[key])) {
result[key] = result[key][0] ?? ''
}
result[key] = String(result[key])
if (validChoices[key] && !validChoices[key].includes(result[key])) {
result[key] = undefined
}
}
}
const toNumber: (keyof ProfileWithoutUser)[] = [
'age',
'height_in_inches',
'drinks_per_month',
'has_kids',
'wants_kids_strength',
'big5_openness',
'big5_conscientiousness',
'big5_extraversion',
'big5_agreeableness',
'big5_neuroticism',
'pref_age_min',
'pref_age_max',
'city_latitude',
'city_longitude',
'raised_in_lat',
'raised_in_lon',
]
for (const key of toNumber) {
if (result[key] !== undefined) {
const num = Number(result[key])
result[key] = isNaN(num) ? undefined : num
}
}
const toBoolean: (keyof ProfileWithoutUser)[] = ['is_smoker']
for (const key of toBoolean) {
if (result[key] !== undefined) {
result[key] = Boolean(result[key])
}
}
if (result.city) {
if (!result.city_latitude || !result.city_longitude) {
const response = await searchLocation({term: result.city, limit: 1})
const locations = response.data?.data
result.city_latitude = locations?.[0]?.latitude
result.city_longitude = locations?.[0]?.longitude
result.country ??= locations?.[0]?.country
}
}
if (result.raised_in_city) {
if (!result.raised_in_lat || !result.raised_in_lon) {
const response = await searchLocation({term: result.raised_in_city, limit: 1})
const locations = response.data?.data
result.raised_in_lat = locations?.[0]?.latitude
result.raised_in_lon = locations?.[0]?.longitude
result.raised_in_country ??= locations?.[0]?.country
}
}
if (result.links) {
const sites = Object.keys(result.links).filter((key) => SITE_ORDER.includes(key as any))
result.links = sites.reduce(
(acc, key) => {
const link = (result.links as Record<string, any>)[key]
if (link) acc[key] = link
return acc
},
{} as Record<string, any>,
)
}
return result
}
async function getCachedResult(cacheKey: string): Promise<Partial<ProfileWithoutUser> | null> { async function getCachedResult(cacheKey: string): Promise<Partial<ProfileWithoutUser> | null> {
if (!USE_CACHE) return null if (!USE_CACHE) return null
try { try {
@@ -180,10 +318,34 @@ async function callLLM(content: string, locale?: string): Promise<Partial<Profil
getOptions('work', locale), getOptions('work', locale),
]) ])
const validChoices: Partial<Record<keyof ProfileWithoutUser, string[]>> = {
interests: INTERESTS,
causes: CAUSE_AREAS,
work: WORK_AREAS,
diet: Object.values(DIET_CHOICES),
ethnicity: Object.values(RACE_CHOICES),
languages: Object.values(LANGUAGE_CHOICES),
religion: Object.values(RELIGION_CHOICES),
political_beliefs: Object.values(POLITICAL_CHOICES),
pref_gender: Object.values(GENDERS),
pref_relation_styles: Object.values(RELATIONSHIP_CHOICES),
pref_romantic_styles: Object.values(ROMANTIC_CHOICES),
relationship_status: Object.values(RELATIONSHIP_STATUS_CHOICES),
cannabis: Object.values(CANNABIS_CHOICES),
education_level: Object.values(EDUCATION_CHOICES),
gender: Object.values(GENDERS),
mbti: Object.values(MBTI_CHOICES),
psychedelics: Object.values(PSYCHEDELICS_CHOICES),
psychedelics_intention: Object.values(SUBSTANCE_INTENTION_CHOICES),
cannabis_intention: Object.values(SUBSTANCE_INTENTION_CHOICES),
psychedelics_pref: Object.values(SUBSTANCE_PREFERENCE_CHOICES),
cannabis_pref: Object.values(SUBSTANCE_PREFERENCE_CHOICES),
}
const PROFILE_FIELDS: Partial<Record<keyof ProfileWithoutUser, any>> = { const PROFILE_FIELDS: Partial<Record<keyof ProfileWithoutUser, any>> = {
// Basic info // Basic info
age: 'Number. Age in years.', age: 'Number. Age in years.',
gender: `One of: ${Object.values(GENDERS).join(', ')}. Infer if you have enough evidence`, gender: `String. One of: ${validChoices.pref_gender?.join(', ')}. If multiple mentioned, use the most likely one. Infer if you have enough evidence`,
height_in_inches: 'Number. Height converted to inches.', height_in_inches: 'Number. Height converted to inches.',
city: 'String. Current city of residence (English spelling).', city: 'String. Current city of residence (English spelling).',
country: 'String. Current country of residence (English spelling).', country: 'String. Current country of residence (English spelling).',
@@ -196,7 +358,7 @@ async function callLLM(content: string, locale?: string): Promise<Partial<Profil
raised_in_lat: 'Number. Latitude of city where they grew up.', raised_in_lat: 'Number. Latitude of city where they grew up.',
raised_in_lon: 'Number. Longitude of city where they grew up.', raised_in_lon: 'Number. Longitude of city where they grew up.',
university: 'String. University or college attended.', university: 'String. University or college attended.',
education_level: `One of: ${Object.values(EDUCATION_CHOICES).join(', ')}`, education_level: `String. One of: ${validChoices.education_level?.join(', ')}. Highest level completed`,
company: 'String. Current employer or company name.', company: 'String. Current employer or company name.',
occupation_title: 'String. Current job title.', occupation_title: 'String. Current job title.',
@@ -206,19 +368,19 @@ async function callLLM(content: string, locale?: string): Promise<Partial<Profil
has_kids: 'Number. 0 if no kids, otherwise number of kids.', has_kids: 'Number. 0 if no kids, otherwise number of kids.',
wants_kids_strength: wants_kids_strength:
'Number 04. How strongly they want kids (0 = definitely not, 4 = definitely yes).', 'Number 04. How strongly they want kids (0 = definitely not, 4 = definitely yes).',
diet: `Array. Any of: ${Object.values(DIET_CHOICES).join(', ')}`, diet: `Array. Any of: ${validChoices.diet?.join(', ')}`,
ethnicity: `Array. Any of: ${Object.values(RACE_CHOICES).join(', ')}`, ethnicity: `Array. Any of: ${validChoices.ethnicity?.join(', ')}`,
// Substances // Substances
psychedelics: `One of: ${Object.values(PSYCHEDELICS_CHOICES).join(', ')}. Usage frequency of psychedelics/plant medicine, only if explicitly stated.`, psychedelics: `String. One of: ${validChoices.psychedelics?.join(', ')}. Usage frequency of psychedelics/plant medicine, only if explicitly stated.`,
cannabis: `One of: ${Object.values(CANNABIS_CHOICES).join(', ')}. Usage frequency of cannabis, only if explicitly stated.`, cannabis: `String. One of: ${validChoices.cannabis?.join(', ')}. Usage frequency of cannabis, only if explicitly stated.`,
psychedelics_intention: `Array. Any of: ${Object.values(SUBSTANCE_INTENTION_CHOICES).join(', ')}. Only if they use psychedelics.`, psychedelics_intention: `String. Array. Any of: ${validChoices.psychedelics_intention?.join(', ')}. Only if they use psychedelics.`,
cannabis_intention: `Array. Any of: ${Object.values(SUBSTANCE_INTENTION_CHOICES).join(', ')}. Only if they use cannabis.`, cannabis_intention: `String. Array. Any of: ${validChoices.cannabis_intention?.join(', ')}. Only if they use cannabis.`,
psychedelics_pref: `Array. Any of: ${Object.values(SUBSTANCE_PREFERENCE_CHOICES).join(', ')}. Partner preference for psychedelics use.`, psychedelics_pref: `String. Array. Any of: ${validChoices.psychedelics_pref?.join(', ')}. Partner preference for psychedelics use.`,
cannabis_pref: `Array. Any of: ${Object.values(SUBSTANCE_PREFERENCE_CHOICES).join(', ')}. Partner preference for cannabis use.`, cannabis_pref: `String. Array. Any of: ${validChoices.cannabis_pref?.join(', ')}. Partner preference for cannabis use.`,
// Identity — big5 only if person explicitly states a score, never infer from personality description // Identity — big5 only if person explicitly states a score, never infer from personality description
mbti: `One of: ${Object.values(MBTI_CHOICES).join(', ')}`, mbti: `String. One of: ${validChoices.mbti?.join(', ')}`,
big5_openness: 'Number 0100. Only if explicitly self-reported, never infer.', big5_openness: 'Number 0100. Only if explicitly self-reported, never infer.',
big5_conscientiousness: 'Number 0100. Only if explicitly self-reported, never infer.', big5_conscientiousness: 'Number 0100. Only if explicitly self-reported, never infer.',
big5_extraversion: 'Number 0100. Only if explicitly self-reported, never infer.', big5_extraversion: 'Number 0100. Only if explicitly self-reported, never infer.',
@@ -226,23 +388,23 @@ async function callLLM(content: string, locale?: string): Promise<Partial<Profil
big5_neuroticism: 'Number 0100. Only if explicitly self-reported, never infer.', big5_neuroticism: 'Number 0100. Only if explicitly self-reported, never infer.',
// Beliefs // Beliefs
religion: `Array. Any of: ${Object.values(RELIGION_CHOICES).join(', ')}`, religion: `Array. Any of: ${validChoices.religion?.join(', ')}`,
religious_beliefs: religious_beliefs:
'String. Free-form elaboration on religious views, only if explicitly stated.', 'String. Free-form elaboration on religious views, only if explicitly stated.',
political_beliefs: `Array. Any of: ${Object.values(POLITICAL_CHOICES).join(', ')}`, political_beliefs: `Array. Any of: ${validChoices.political_beliefs?.join(', ')}`,
political_details: political_details:
'String. Free-form elaboration on political views, only if explicitly stated.', 'String. Free-form elaboration on political views, only if explicitly stated.',
// Preferences // Preferences
pref_age_min: 'Number. Minimum preferred age of match.', pref_age_min: 'Number. Minimum preferred age of match.',
pref_age_max: 'Number. Maximum preferred age of match.', pref_age_max: 'Number. Maximum preferred age of match.',
pref_gender: `Array. Any of: ${Object.values(GENDERS).join(', ')}`, pref_gender: `Array. Any of: ${validChoices.pref_gender?.join(', ')}`,
pref_relation_styles: `Array. Any of: ${Object.values(RELATIONSHIP_CHOICES).join(', ')}`, pref_relation_styles: `Array. Any of: ${validChoices.pref_relation_styles?.join(', ')}`,
pref_romantic_styles: `Array. Any of: ${Object.values(ROMANTIC_CHOICES).join(', ')}`, pref_romantic_styles: `Array. Any of: ${validChoices.pref_romantic_styles?.join(', ')}`,
relationship_status: `Array. Any of: ${Object.values(RELATIONSHIP_STATUS_CHOICES).join(', ')}`, relationship_status: `Array. Any of: ${validChoices.relationship_status?.join(', ')}`,
// Languages // Languages
languages: `Array. Any of: ${Object.values(LANGUAGE_CHOICES).join(', ')}. If none, infer from text.`, languages: `Array. Any of: ${validChoices.languages?.join(', ')}. If none, infer from text.`,
// Free-form // Free-form
headline: headline:
@@ -251,9 +413,9 @@ async function callLLM(content: string, locale?: string): Promise<Partial<Profil
links: `Object. Key is any of: ${SITE_ORDER.join(', ')}.`, links: `Object. Key is any of: ${SITE_ORDER.join(', ')}.`,
// Taxonomies — match existing labels first, only add new if truly no close match exists // Taxonomies — match existing labels first, only add new if truly no close match exists
interests: `Array. Prefer existing labels, only add new if no close match. Any of: ${INTERESTS.join(', ')}`, interests: `Array. Prefer existing labels, only add new if no close match. Any of: ${validChoices.interests?.join(', ')}`,
causes: `Array. Prefer existing labels, only add new if no close match. Any of: ${CAUSE_AREAS.join(', ')}`, causes: `Array. Prefer existing labels, only add new if no close match. Any of: ${validChoices.causes?.join(', ')}`,
work: `Array. Use only existing labels, do not add new if no close match. Any of: ${WORK_AREAS.join(', ')}`, work: `Array. Use only existing labels, do not add new if no close match. Any of: ${validChoices.work?.join(', ')}`,
} }
const EXTRACTION_PROMPT = `You are a profile information extraction expert analyzing text from a personal webpage, LinkedIn, bio, or similar source. const EXTRACTION_PROMPT = `You are a profile information extraction expert analyzing text from a personal webpage, LinkedIn, bio, or similar source.
@@ -296,42 +458,13 @@ TEXT TO ANALYZE:
let parsed: Partial<ProfileWithoutUser> let parsed: Partial<ProfileWithoutUser>
try { try {
parsed = typeof outputText === 'string' ? JSON.parse(outputText) : outputText parsed = typeof outputText === 'string' ? JSON.parse(outputText) : outputText
parsed = await validateProfileFields(parsed, validChoices)
parsed = removeNullOrUndefinedProps(parsed) parsed = removeNullOrUndefinedProps(parsed)
} catch (parseError) { } catch (parseError) {
log('Failed to parse LLM response as JSON', {outputText, parseError}) log('Failed to parse LLM response as JSON', {outputText, parseError})
throw APIErrors.internalServerError('Failed to parse extracted data') throw APIErrors.internalServerError('Failed to parse extracted data')
} }
if (parsed.city) {
if (!parsed.city_latitude || !parsed.city_longitude) {
const result = await searchLocation({term: parsed.city, limit: 1})
const locations = result.data?.data
parsed.city_latitude = locations?.[0]?.latitude
parsed.city_longitude = locations?.[0]?.longitude
parsed.country ??= locations?.[0]?.country
}
}
if (parsed.raised_in_city) {
if (!parsed.raised_in_lat || !parsed.raised_in_lon) {
const result = await searchLocation({term: parsed.raised_in_city, limit: 1})
const locations = result.data?.data
parsed.raised_in_lat = locations?.[0]?.latitude
parsed.raised_in_lon = locations?.[0]?.longitude
parsed.raised_in_country ??= locations?.[0]?.country
}
}
if (parsed.links) {
const sites = Object.keys(parsed.links).filter((key) => SITE_ORDER.includes(key as any))
parsed.links = sites.reduce(
(acc, key) => {
const link = (parsed.links as Record<string, any>)[key]
if (link) acc[key] = link
return acc
},
{} as Record<string, any>,
)
}
await setCachedResult(cacheKey, parsed) await setCachedResult(cacheKey, parsed)
return parsed return parsed

View File

@@ -55,7 +55,11 @@ export const LOCAL_BACKEND_DOMAIN = `${IS_WEBVIEW_DEV_PHONE ? '192.168.1.3' : IS
export const DOMAIN = IS_LOCAL ? LOCAL_WEB_DOMAIN : ENV_CONFIG.domain export const DOMAIN = IS_LOCAL ? LOCAL_WEB_DOMAIN : ENV_CONFIG.domain
export const DEPLOYED_WEB_URL = `https://www.${ENV_CONFIG.domain}` export const DEPLOYED_WEB_URL = `https://www.${ENV_CONFIG.domain}`
export const WEB_URL = IS_LOCAL ? `http://${LOCAL_WEB_DOMAIN}` : `https://www.${DOMAIN}`
// Careful: buildOgUrl uses WEB_URL and works only on https://www.compassmeet.com (not https://compassmeet.com)
// x-vercel-error: INVALID_IMAGE_OPTIMIZE_REQUEST. Could work if needed though with some Vercel tweak
export const WEB_URL = IS_LOCAL ? `http://${LOCAL_WEB_DOMAIN}` : `https://${DOMAIN}`
export const BACKEND_DOMAIN = IS_LOCAL ? LOCAL_BACKEND_DOMAIN : ENV_CONFIG.backendDomain export const BACKEND_DOMAIN = IS_LOCAL ? LOCAL_BACKEND_DOMAIN : ENV_CONFIG.backendDomain
export const FIREBASE_CONFIG = ENV_CONFIG.firebaseConfig export const FIREBASE_CONFIG = ENV_CONFIG.firebaseConfig
export const PROJECT_ID = ENV_CONFIG.firebaseConfig.projectId export const PROJECT_ID = ENV_CONFIG.firebaseConfig.projectId

View File

@@ -66,7 +66,9 @@ export async function run<T>(
export async function run<T>(q: PromiseLike<PostgrestSingleResponse<T> | PostgrestResponse<T>>) { export async function run<T>(q: PromiseLike<PostgrestSingleResponse<T> | PostgrestResponse<T>>) {
const {data, count, error} = await q const {data, count, error} = await q
if (error != null) { if (error != null) {
throw error const err = new Error(error.message)
Object.assign(err, error) // copies code, details, hint onto the Error
throw err
} else { } else {
return {data, count} return {data, count}
} }

View File

@@ -348,6 +348,7 @@ jest.mock('path/to/module')
* This creates an object containing all named exports from ./path/to/module * This creates an object containing all named exports from ./path/to/module
*/ */
import * as mockModule from 'path/to/module' import * as mockModule from 'path/to/module'
;(mockModule.module as jest.Mock).mockResolvedValue(mockReturnValue) ;(mockModule.module as jest.Mock).mockResolvedValue(mockReturnValue)
``` ```
@@ -705,6 +706,66 @@ Use this priority order for selecting elements in Playwright tests:
This hierarchy mirrors how users actually interact with your application, making tests more reliable and meaningful. This hierarchy mirrors how users actually interact with your application, making tests more reliable and meaningful.
### Page Object Model (POM)
Tests often receive multiple page objects as fixtures (e.g. `homePage`, `authPage`, `profilePage`). This is the **Page
Object Model** pattern — a way to organize selectors and actions by the area of the app they belong to.
**Page objects are not separate browser tabs.** They are all wrappers around the same underlying `page` instance. Each
class simply encapsulates the selectors and actions relevant to one part of the UI:
```typescript
class ProfilePage {
constructor(private page: Page) {}
async verifyDisplayName(name: string) {
await expect(this.page.getByTestId('display-name')).toHaveText(name)
}
}
class SettingsPage {
constructor(private page: Page) {} // same page instance
async deleteAccount() {
await this.page.getByRole('button', {name: 'Delete account'}).click()
}
}
```
**Why use POM instead of raw `page`?**
Without it, tests are full of inline selectors that are brittle and hard to read. POM moves implementation details into
dedicated classes so that the test itself reads like a plain-English description of user behavior. When a selector
changes, you fix it in one place.
```typescript
// ❌ Without POM — noisy and brittle
await page.locator('#email-input').fill(account.email)
await page.locator('#submit-btn').click()
await page.locator('[data-testid="skip-onboarding"]').click()
// ...50 more lines of noise
// ✅ With POM — readable and maintainable
await registerWithEmail(homePage, authPage, fakerAccount)
await skipOnboardingHeadToProfile(onboardingPage, signUpPage, profilePage, fakerAccount)
await profilePage.verifyDisplayName(fakerAccount.display_name)
```
**What happens if you call a method on the "wrong" page object?**
Nothing special — it still runs. Since all page objects share the same `page`, the method simply acts on whatever is
currently rendered in the browser. Page objects do not track which screen you're on; that's your responsibility as the
test author. If you call `profilePage.verifyDisplayName()` while the browser is showing the settings screen, the locator
won't find its element and the test will **time out**.
```typescript
// ⚠️ This fails at runtime if navigation hasn't happened yet
await settingsPage.deleteAccount() // navigates away from profile
await profilePage.verifyDisplayName(name) // locator not found → timeout
```
Always ensure navigation has completed before calling methods that depend on a specific screen being visible.
### Setting up test data ### Setting up test data
Since the tests run in parallel (i.e., at the same time) and share the same database and Firebase emulator, it can Since the tests run in parallel (i.e., at the same time) and share the same database and Firebase emulator, it can
@@ -802,7 +863,9 @@ These are seeded automatically by `yarn test:db:seed`:
## Troubleshooting ## Troubleshooting
For comprehensive troubleshooting guidance beyond testing-specific issues, see the [Troubleshooting Guide](TROUBLESHOOTING.md) which covers development environment setup, database and emulator issues, API problems, and more. For comprehensive troubleshooting guidance beyond testing-specific issues, see
the [Troubleshooting Guide](TROUBLESHOOTING.md) which covers development environment setup, database and emulator
issues, API problems, and more.
### Port already in use ### Port already in use
@@ -885,4 +948,5 @@ To download the Playwright report from a failed CI run:
3. Download `playwright-report` 3. Download `playwright-report`
4. Open `index.html` in your browser 4. Open `index.html` in your browser
For performance testing guidance and benchmarking strategies, see the [Performance Optimization Guide](PERFORMANCE_OPTIMIZATION.md). For performance testing guidance and benchmarking strategies, see
the [Performance Optimization Guide](PERFORMANCE_OPTIMIZATION.md).

View File

@@ -38,7 +38,10 @@ export default defineConfig({
// If there is a known intermittent browser timing issue for some tests, it's fine to give 1 retry to those flaky tests // If there is a known intermittent browser timing issue for some tests, it's fine to give 1 retry to those flaky tests
retries: process.env.CI ? 0 : 0, retries: process.env.CI ? 0 : 0,
workers: process.env.CI ? 1 : undefined, workers: process.env.CI ? 1 : undefined,
reporter: [['html', {outputFolder: `tests/reports/playwright-report`, open: 'on-failure'}]], reporter: [
['list'],
['html', {outputFolder: `tests/reports/playwright-report`, open: 'on-failure'}],
],
use: { use: {
baseURL: 'http://localhost:3000', baseURL: 'http://localhost:3000',
trace: 'on-first-retry', trace: 'on-first-retry',

View File

@@ -12,8 +12,9 @@ import {
SUBSTANCE_PREFERENCE_CHOICES, SUBSTANCE_PREFERENCE_CHOICES,
} from 'common/choices' } from 'common/choices'
class UserAccountInformation { class UserAccountInformationForSeeding {
name = faker.person.fullName() name = faker.person.fullName()
userName = faker.internet.displayName()
email = faker.internet.email() email = faker.internet.email()
user_id = faker.string.alpha(28) user_id = faker.string.alpha(28)
password = faker.internet.password() password = faker.internet.password()
@@ -54,4 +55,4 @@ class UserAccountInformation {
} }
} }
export default UserAccountInformation export default UserAccountInformationForSeeding

View File

@@ -6,7 +6,8 @@ export async function deleteFromDb(user_id: string) {
const result = await db.query(deleteEntryById, [user_id]) const result = await db.query(deleteEntryById, [user_id])
if (!result.length) { if (!result.length) {
throw new Error(`No user found with id: ${user_id}`) console.debug(`No user found with id: ${user_id}`)
return
} }
console.log('Deleted data: ', { console.log('Deleted data: ', {
@@ -19,8 +20,8 @@ export async function deleteFromDb(user_id: string) {
export async function userInformationFromDb(account: any) { export async function userInformationFromDb(account: any) {
const db = createSupabaseDirectClient() const db = createSupabaseDirectClient()
const queryUserById = ` const queryUserById = `
SELECT p.* SELECT *
FROM users AS p FROM users
WHERE username = $1 WHERE username = $1
` `
const userResults = await db.query(queryUserById, [account.username]) const userResults = await db.query(queryUserById, [account.username])

View File

@@ -2,7 +2,10 @@ import axios from 'axios'
import {config} from '../web/SPEC_CONFIG' import {config} from '../web/SPEC_CONFIG'
export async function firebaseLogin(email: string, password: string) { export async function firebaseLoginEmailPassword(
email: string | undefined,
password: string | undefined,
) {
const login = await axios.post( const login = await axios.post(
`${config.FIREBASE_URL.BASE}${config.FIREBASE_URL.SIGN_IN_PASSWORD}`, `${config.FIREBASE_URL.BASE}${config.FIREBASE_URL.SIGN_IN_PASSWORD}`,
{ {
@@ -13,15 +16,28 @@ export async function firebaseLogin(email: string, password: string) {
) )
return login return login
} }
export async function getUserId(email: string, password: string) { export async function getUserId(email: string, password: string) {
try { try {
const loginInfo = await firebaseLogin(email, password) const loginInfo = await firebaseLoginEmailPassword(email, password)
return loginInfo.data.localId return loginInfo.data.localId
} catch { } catch {
return return
} }
} }
export async function findUser(idToken: string) {
const response = await axios.post(
`${config.FIREBASE_URL.BASE}${config.FIREBASE_URL.ACCOUNT_LOOKUP}`,
{
idToken,
},
)
if (response?.data?.users?.length > 0) {
return response.data.users[0]
}
}
export async function firebaseSignUp(email: string, password: string) { export async function firebaseSignUp(email: string, password: string) {
try { try {
const response = await axios.post(`${config.FIREBASE_URL.BASE}${config.FIREBASE_URL.SIGNUP}`, { const response = await axios.post(`${config.FIREBASE_URL.BASE}${config.FIREBASE_URL.SIGNUP}`, {
@@ -46,8 +62,15 @@ export async function firebaseSignUp(email: string, password: string) {
} }
} }
export async function deleteAccount(login: any) { export async function deleteAccount(idToken: any) {
await axios.post(`${config.FIREBASE_URL.BASE}${config.FIREBASE_URL.DELETE}`, { try {
idToken: login.data.idToken, await axios.post(`${config.FIREBASE_URL.BASE}${config.FIREBASE_URL.DELETE}`, {
}) idToken: idToken,
})
} catch (err: any) {
if (err.response?.data?.error?.message?.includes('USER_NOT_FOUND')) {
return
}
throw err
}
} }

View File

@@ -7,7 +7,7 @@ import {createSupabaseDirectClient} from 'shared/supabase/init'
import {insert} from 'shared/supabase/utils' import {insert} from 'shared/supabase/utils'
import {getUser} from 'shared/utils' import {getUser} from 'shared/utils'
import UserAccountInformation from '../backend/utils/userInformation' import UserAccountInformationForSeeding from '../backend/utils/userInformation'
import {firebaseSignUp} from './firebaseUtils' import {firebaseSignUp} from './firebaseUtils'
/** /**
@@ -16,7 +16,10 @@ import {firebaseSignUp} from './firebaseUtils'
* @param userInfo - Class object containing information to create a user account generated by `fakerjs`. * @param userInfo - Class object containing information to create a user account generated by `fakerjs`.
* @param profileType - Optional param used to signify how much information is used in the account generation. * @param profileType - Optional param used to signify how much information is used in the account generation.
*/ */
export async function seedDbUser(userInfo: UserAccountInformation, profileType?: string) { export async function seedDbUser(
userInfo: UserAccountInformationForSeeding,
profileType?: string,
): Promise<Boolean> {
const pg = createSupabaseDirectClient() const pg = createSupabaseDirectClient()
const userId = userInfo.user_id const userId = userInfo.user_id
const deviceToken = randomString() const deviceToken = randomString()
@@ -83,14 +86,14 @@ export async function seedDbUser(userInfo: UserAccountInformation, profileType?:
blockedByUserIds: [], blockedByUserIds: [],
} }
await pg.tx(async (tx: any) => { return pg.tx(async (tx: any) => {
const preexistingUser = await getUser(userId, tx) const preexistingUser = await getUser(userId, tx)
if (preexistingUser) return if (preexistingUser) return false
await insert(tx, 'users', { await insert(tx, 'users', {
id: userId, id: userId,
name: userInfo.name, name: userInfo.name,
username: cleanUsername(userInfo.name), username: cleanUsername(userInfo.userName),
data: {}, data: {},
}) })
@@ -100,6 +103,7 @@ export async function seedDbUser(userInfo: UserAccountInformation, profileType?:
}) })
await insert(tx, 'profiles', profileData) await insert(tx, 'profiles', profileData)
return true
}) })
} }
@@ -107,13 +111,17 @@ export async function seedUser(
email?: string | undefined, email?: string | undefined,
password?: string | undefined, password?: string | undefined,
profileType?: string | undefined, profileType?: string | undefined,
displayName?: string | undefined,
userName?: string | undefined,
) { ) {
const userInfo = new UserAccountInformation() const userInfo = new UserAccountInformationForSeeding()
if (email) userInfo.email = email if (email) userInfo.email = email
if (password) userInfo.password = password if (password) userInfo.password = password
if (displayName) userInfo.name = displayName
if (userName) userInfo.userName = userName
userInfo.user_id = await firebaseSignUp(userInfo.email, userInfo.password) userInfo.user_id = await firebaseSignUp(userInfo.email, userInfo.password)
if (userInfo.user_id) { if (userInfo.user_id) {
await seedDbUser(userInfo, profileType ?? 'full') const created = await seedDbUser(userInfo, profileType ?? 'full')
if (created) debug('User created in Firebase and Supabase:', userInfo.email)
} }
debug('User created in Firebase and Supabase:', userInfo.email)
} }

View File

@@ -4,6 +4,7 @@ export const config = {
BASE: 'http://localhost:9099/identitytoolkit.googleapis.com/v1', BASE: 'http://localhost:9099/identitytoolkit.googleapis.com/v1',
SIGNUP: '/accounts:signUp?key=fake-api-key', SIGNUP: '/accounts:signUp?key=fake-api-key',
SIGN_IN_PASSWORD: '/accounts:signInWithPassword?key=fake-api-key', SIGN_IN_PASSWORD: '/accounts:signInWithPassword?key=fake-api-key',
ACCOUNT_LOOKUP: '/accounts:lookup?key=fake-api-key',
DELETE: '/accounts:delete?key=fake-api-key', DELETE: '/accounts:delete?key=fake-api-key',
}, },
USERS: { USERS: {

View File

@@ -4,10 +4,14 @@ import {AuthPage} from '../pages/AuthPage'
import {ComatibilityPage} from '../pages/compatibilityPage' import {ComatibilityPage} from '../pages/compatibilityPage'
import {HomePage} from '../pages/homePage' import {HomePage} from '../pages/homePage'
import {OnboardingPage} from '../pages/onboardingPage' import {OnboardingPage} from '../pages/onboardingPage'
import {OrganizationPage} from '../pages/organizationPage'
import {ProfilePage} from '../pages/profilePage' import {ProfilePage} from '../pages/profilePage'
import {SettingsPage} from '../pages/settingsPage'
import {SignUpPage} from '../pages/signUpPage' import {SignUpPage} from '../pages/signUpPage'
import {SocialPage} from '../pages/socialPage'
import {testAccounts, UserAccountInformation} from '../utils/accountInformation' import {testAccounts, UserAccountInformation} from '../utils/accountInformation'
import {deleteUser} from '../utils/deleteUser' import {deleteUser} from '../utils/deleteUser'
import {getAuthAccountInfo} from '../utils/networkUtils'
export const test = base.extend<{ export const test = base.extend<{
homePage: HomePage homePage: HomePage
@@ -15,29 +19,48 @@ export const test = base.extend<{
signUpPage: SignUpPage signUpPage: SignUpPage
profilePage: ProfilePage profilePage: ProfilePage
authPage: AuthPage authPage: AuthPage
settingsPage: SettingsPage
socialPage: SocialPage
organizationPage: OrganizationPage
compatabilityPage: ComatibilityPage compatabilityPage: ComatibilityPage
cleanUpUsers: void cleanUpUsers: void
onboardingAccount: UserAccountInformation onboardingAccount: UserAccountInformation
fakerAccount: UserAccountInformation fakerAccount: UserAccountInformation
specAccount: UserAccountInformation specAccount: UserAccountInformation
googleAccountOne: UserAccountInformation
googleAccountTwo: UserAccountInformation
}>({ }>({
onboardingAccount: async ({}, use) => { onboardingAccount: async ({}, use) => {
const account = testAccounts.account_all_info() // email captured here const account = testAccounts.email_account_all_info() // email captured here
await use(account) await use(account)
console.log('Cleaning up onboarding 1 account...') console.log('Cleaning up onboarding 1 account...')
await deleteUser(account.email, account.password) // same account, guaranteed await deleteUser('Email/Password', account) // same account, guaranteed
}, },
fakerAccount: async ({}, use) => { fakerAccount: async ({}, use) => {
const account = testAccounts.faker_account() // email captured here const account = testAccounts.faker_account()
await use(account) await use(account)
console.log('Cleaning up faker account...') console.log('Cleaning up faker account...')
await deleteUser(account.email, account.password) // same account, guaranteed await deleteUser('Email/Password', account)
},
googleAccountOne: async ({page}, use) => {
const account = testAccounts.google_account_one()
const getAuthObject = await getAuthAccountInfo(page)
await use(account)
console.log('Cleaning up google account...')
await deleteUser('Google', undefined, getAuthObject())
},
googleAccountTwo: async ({page}, use) => {
const account = testAccounts.google_account_two()
const getAuthObject = await getAuthAccountInfo(page)
await use(account)
console.log('Cleaning up google account...')
await deleteUser('Google', undefined, getAuthObject())
}, },
specAccount: async ({}, use) => { specAccount: async ({}, use) => {
const account = testAccounts.spec_account() const account = testAccounts.spec_account()
await use(account) await use(account)
console.log('Cleaning up spec account...') console.log('Cleaning up spec account...')
await deleteUser(account.email, account.password) await deleteUser('Email/Password', account)
}, },
onboardingPage: async ({page}, use) => { onboardingPage: async ({page}, use) => {
const onboardingPage = new OnboardingPage(page) const onboardingPage = new OnboardingPage(page)
@@ -63,6 +86,18 @@ export const test = base.extend<{
const compatibilityPage = new ComatibilityPage(page) const compatibilityPage = new ComatibilityPage(page)
await use(compatibilityPage) await use(compatibilityPage)
}, },
settingsPage: async ({page}, use) => {
const settingsPage = new SettingsPage(page)
await use(settingsPage)
},
socialPage: async ({page}, use) => {
const socialPage = new SocialPage(page)
await use(socialPage)
},
organizationPage: async ({page}, use) => {
const organizationPage = new OrganizationPage(page)
await use(organizationPage)
},
}) })
export {expect} from '@playwright/test' export {expect} from '@playwright/test'

View File

@@ -1,33 +1,64 @@
import {expect, Page, test as base} from '@playwright/test' import {test as base} from '@playwright/test'
import {seedUser} from '../../utils/seedDatabase'
import {AuthPage} from '../pages/AuthPage' import {AuthPage} from '../pages/AuthPage'
import {config} from '../SPEC_CONFIG' import {HomePage} from '../pages/homePage'
import {testAccounts, UserAccountInformation} from '../utils/accountInformation'
import { OnboardingPage } from '../pages/onboardingPage'
import { SignUpPage } from '../pages/signUpPage'
import { ProfilePage } from '../pages/profilePage'
import { SettingsPage } from '../pages/settingsPage'
export const test = base.extend<{ export const test = base.extend<{
authenticatedPage: Page homePage: HomePage
onboardingPage: OnboardingPage
signUpPage: SignUpPage
profilePage: ProfilePage
settingsPage: SettingsPage
authPage: AuthPage
dev_one_account: UserAccountInformation
fakerAccount: UserAccountInformation
googleAccountOne: UserAccountInformation
googleAccountTwo: UserAccountInformation
}>({ }>({
authenticatedPage: async ({page}, use) => { homePage: async ({page}, use) => {
const homePage = new HomePage(page)
await use(homePage)
},
onboardingPage: async ({page}, use) => {
const onboardingPage = new OnboardingPage(page)
await use(onboardingPage)
},
signUpPage: async ({page}, use) => {
const signUpPage = new SignUpPage(page)
await use(signUpPage)
},
profilePage: async ({page}, use) => {
const profilePage = new ProfilePage(page)
await use(profilePage)
},
settingsPage: async ({page}, use) => {
const settingsPage = new SettingsPage(page)
await use(settingsPage)
},
authPage: async ({page}, use) => {
const authPage = new AuthPage(page) const authPage = new AuthPage(page)
await use(authPage)
const email = config.USERS.DEV_1.EMAIL },
const password = config.USERS.DEV_1.PASSWORD dev_one_account: async ({}, use) => {
const account = testAccounts.dev_one_account()
try { await use(account)
await seedUser(email, password) },
} catch (_e) { fakerAccount: async ({}, use) => {
console.log('User already exists for signinFixture', email) const account = testAccounts.faker_account()
} await use(account)
},
await page.goto('/signin') googleAccountOne: async ({}, use) => {
await authPage.fillEmailField(email) const account = testAccounts.google_account_one()
await authPage.fillPasswordField(password) await use(account)
await authPage.clickSignInWithEmailButton() },
googleAccountTwo: async ({}, use) => {
await page.waitForURL(/^(?!.*signin).*$/) const account = testAccounts.google_account_two()
await use(account)
expect(page.url()).not.toContain('/signin')
await use(page)
}, },
}) })
export {expect} from '@playwright/test'

View File

@@ -7,7 +7,7 @@ export class AuthPage {
private readonly emailField: Locator private readonly emailField: Locator
private readonly passwordField: Locator private readonly passwordField: Locator
private readonly signInWithEmailButton: Locator private readonly signInWithEmailButton: Locator
private readonly signInWithGoogleButton: Locator private readonly googleButton: Locator
private readonly signUpWithEmailButton: Locator private readonly signUpWithEmailButton: Locator
constructor(public readonly page: Page) { constructor(public readonly page: Page) {
@@ -16,7 +16,7 @@ export class AuthPage {
this.emailField = page.getByLabel('Email') this.emailField = page.getByLabel('Email')
this.passwordField = page.getByLabel('Password') this.passwordField = page.getByLabel('Password')
this.signInWithEmailButton = page.getByRole('button', {name: 'Sign in with Email'}) this.signInWithEmailButton = page.getByRole('button', {name: 'Sign in with Email'})
this.signInWithGoogleButton = page.getByRole('button', {name: 'Google'}) this.googleButton = page.getByRole('button', {name: 'Google'})
this.signUpWithEmailButton = page.getByRole('button', {name: 'Sign up with Email'}) this.signUpWithEmailButton = page.getByRole('button', {name: 'Sign up with Email'})
} }
@@ -35,9 +35,28 @@ export class AuthPage {
await this.signInWithEmailButton.click() await this.signInWithEmailButton.click()
} }
async clickSignInWithGoogleButton() { async clickGoogleButton() {
await expect(this.signInWithGoogleButton).toBeVisible() await expect(this.googleButton).toBeVisible()
await this.signInWithGoogleButton.click() await this.googleButton.click()
}
async getGooglePopupPage(): Promise<Page> {
const [popup] = await Promise.all([
this.page.context().waitForEvent('page'),
this.clickGoogleButton(),
])
await popup.waitForLoadState()
return popup
}
async signInToGoogleAccount(email: string, display_name?: string, username?: string) {
const popup = await this.getGooglePopupPage()
await popup.getByText('Add new account', {exact: true}).click()
await popup.getByLabel('Email').fill(email)
if (display_name) await popup.getByLabel('Display name').fill(display_name)
if (username) await popup.getByLabel('Screen name', {exact: true}).fill(username)
await popup.getByText('Sign in with Google.com', {exact: true}).click()
await popup.waitForEvent('close')
} }
async clickSignUpWithEmailButton() { async clickSignUpWithEmailButton() {

View File

@@ -2,78 +2,144 @@ import {expect, Locator, Page} from '@playwright/test'
import {LocaleTuple} from 'common/constants' import {LocaleTuple} from 'common/constants'
export class HomePage { export class HomePage {
private readonly sidebar: Locator
private readonly homePageLink: Locator private readonly homePageLink: Locator
private readonly aboutLink: Locator private readonly profileLink: Locator
private readonly faqLink: Locator
private readonly voteLink: Locator
private readonly eventsLink: Locator
private readonly whatsNewLink: Locator
private readonly socialsLink: Locator
private readonly organizationLink: Locator
private readonly contactLink: Locator
private readonly signUpButton: Locator private readonly signUpButton: Locator
private readonly localePicker: Locator private readonly localePicker: Locator
private readonly signInLink: Locator private readonly signInLink: Locator
private readonly signOutLink: Locator
private readonly closeButton: Locator private readonly closeButton: Locator
constructor(public readonly page: Page) { constructor(public readonly page: Page) {
this.homePageLink = page.getByText('Compass', {exact: true}) this.sidebar = page.getByTestId('sidebar')
this.aboutLink = page.getByTestId('sidebar-about') this.homePageLink = page.locator('a[href="/home"]')
this.faqLink = page.getByTestId('sidebar-faq') this.profileLink = page.getByTestId('sidebar-username')
this.voteLink = page.getByTestId('sidebar-vote')
this.eventsLink = page.getByTestId('sidebar-events')
this.whatsNewLink = page.getByTestId('sidebar-news')
this.socialsLink = page.getByTestId('sidebar-social')
this.organizationLink = page.getByTestId('sidebar-organization')
this.contactLink = page.getByTestId('sidebar-contact')
this.signUpButton = page.locator('button').filter({hasText: 'Sign up'}).first() this.signUpButton = page.locator('button').filter({hasText: 'Sign up'}).first()
this.localePicker = page.getByTestId('sidebar-locale-picker') this.localePicker = page.getByTestId('sidebar-locale-picker')
this.signInLink = page.getByTestId('sidebar-signin') this.signInLink = page.locator('a[href="/signin"]').first()
this.signOutLink = page.getByText('Sign out', {exact: true})
this.closeButton = page.getByRole('button', {name: 'Close'}) this.closeButton = page.getByRole('button', {name: 'Close'})
} }
async gotToHomePage() { get sidebarAbout() {
await this.page.goto('/') return this.sidebar.getByText('About')
}
get sidebarFaq() {
return this.sidebar.getByText('FAQ')
}
get sidebarVote() {
return this.sidebar.getByText('Vote')
}
get sidebarEvents() {
return this.sidebar.getByText('Events')
}
get sidebarWhatsNew() {
return this.sidebar.getByText("What's new")
}
get sidebarSocials() {
return this.sidebar.getByText('Socials')
}
get sidebarOrganization() {
return this.sidebar.getByText('Organization')
}
get sidebarSettings() {
return this.sidebar.getByText('Settings')
}
get sidebarPeople() {
return this.sidebar.getByText('People')
}
get sidebarNotifs() {
return this.sidebar.getByText('Notifs')
}
get sidebarMessages() {
return this.sidebar.getByText('Messages')
}
get sidebarContact() {
return this.sidebar.getByText('Contact')
}
async goToHomePage() {
await this.page.goto('/home')
}
async goToRegisterPage() {
await this.page.goto('/register')
}
async goToSigninPage() {
await this.page.goto('/signin')
} }
async clickAboutLink() { async clickAboutLink() {
await expect(this.aboutLink).toBeVisible() await expect(this.sidebarAbout).toBeVisible()
await this.aboutLink.click() await this.sidebarAbout.click()
} }
async clickFaqLink() { async clickFaqLink() {
await expect(this.faqLink).toBeVisible() await expect(this.sidebarFaq).toBeVisible()
await this.faqLink.click() await this.sidebarFaq.click()
} }
async clickVoteLink() { async clickVoteLink() {
await expect(this.voteLink).toBeVisible() await expect(this.sidebarVote).toBeVisible()
await this.voteLink.click() await this.sidebarVote.click()
} }
async clickEventsLink() { async clickEventsLink() {
await expect(this.eventsLink).toBeVisible() await expect(this.sidebarEvents).toBeVisible()
await this.eventsLink.click() await this.sidebarEvents.click()
} }
async clickWhatsNewLink() { async clickWhatsNewLink() {
await expect(this.whatsNewLink).toBeVisible() await expect(this.sidebarWhatsNew).toBeVisible()
await this.whatsNewLink.click() await this.sidebarWhatsNew.click()
} }
async clickSocialsLink() { async clickSocialsLink() {
await expect(this.socialsLink).toBeVisible() await expect(this.sidebarSocials).toBeVisible()
await this.socialsLink.click() await this.sidebarSocials.click()
} }
async clickOrganizationLink() { async clickOrganizationLink() {
await expect(this.organizationLink).toBeVisible() await expect(this.sidebarOrganization).toBeVisible()
await this.organizationLink.click() await this.sidebarOrganization.click()
} }
async clickContactLink() { async clickContactLink() {
await expect(this.contactLink).toBeVisible() await expect(this.sidebarContact).toBeVisible()
await this.contactLink.click() await this.sidebarContact.click()
}
async clickSettingsLink() {
await expect(this.sidebarSettings).toBeVisible()
await this.sidebarSettings.click()
}
async clickPeopleLink() {
await expect(this.sidebarPeople).toBeVisible()
await this.sidebarPeople.click()
}
async clickNotifsLink() {
await expect(this.sidebarNotifs).toBeVisible()
await this.sidebarNotifs.click()
}
async clickMessagesLink() {
await expect(this.sidebarMessages).toBeVisible()
await this.sidebarMessages.click()
} }
async clickSignUpButton() { async clickSignUpButton() {
@@ -88,7 +154,44 @@ export class HomePage {
} }
async clickSignInLink() { async clickSignInLink() {
await expect(this.sidebar).toBeVisible()
await this.sidebar.getByText('Sign in').click()
}
async verifyHomePageLinks() {
await expect(this.homePageLink).toBeVisible()
await expect(this.sidebarAbout).toBeVisible()
await expect(this.sidebarFaq).toBeVisible()
await expect(this.sidebarVote).toBeVisible()
await expect(this.sidebarEvents).toBeVisible()
await expect(this.sidebarWhatsNew).toBeVisible()
await expect(this.sidebarSocials).toBeVisible()
await expect(this.sidebarOrganization).toBeVisible()
await expect(this.sidebarContact).toBeVisible()
await expect(this.signUpButton).toBeVisible()
await expect(this.signInLink).toBeVisible() await expect(this.signInLink).toBeVisible()
await this.signInLink.click() await expect(this.localePicker).toBeVisible()
}
async verifySignedInHomePage(displayName: string) {
await expect(this.homePageLink).toBeVisible()
await expect(this.profileLink).toBeVisible()
await expect(this.profileLink).toContainText(displayName)
await expect(this.sidebarPeople).toBeVisible()
await expect(this.sidebarNotifs).toBeVisible()
await expect(this.sidebarMessages).toBeVisible()
await expect(this.sidebarSettings).toBeVisible()
await expect(this.sidebarAbout).toBeVisible()
await expect(this.sidebarFaq).toBeVisible()
await expect(this.sidebarVote).toBeVisible()
await expect(this.sidebarEvents).toBeVisible()
await expect(this.sidebarWhatsNew).toBeVisible()
await expect(this.sidebarSocials).toBeVisible()
await expect(this.sidebarOrganization).toBeVisible()
await expect(this.sidebarContact).toBeVisible()
await expect(this.signOutLink).toBeVisible()
await expect(this.signUpButton).not.toBeVisible()
await expect(this.signInLink).not.toBeVisible()
await expect(this.localePicker).not.toBeVisible()
} }
} }

View File

@@ -0,0 +1,134 @@
import {expect, Locator, Page} from '@playwright/test'
export class OrganizationPage {
private readonly pageTitle: Locator
private readonly aboutUsHeading: Locator
private readonly proofAndTransparencyHeading: Locator
private readonly contactAndSupportHeading: Locator
private readonly trustAndLegalHeading: Locator
private readonly aboutCompassLink: Locator
private readonly constitutionLink: Locator
private readonly keyMetricsLink: Locator
private readonly pressLink: Locator
private readonly financialTransparencyLink: Locator
private readonly contactUsLink: Locator
private readonly helpAndSupportLink: Locator
private readonly securityLink: Locator
private readonly termsAndConditionsLink: Locator
private readonly privacyPolicyLink: Locator
constructor(public readonly page: Page) {
this.pageTitle = page.getByRole('heading', {name: 'Organization'})
this.aboutUsHeading = page.getByRole('heading', {name: 'About us'})
this.proofAndTransparencyHeading = page.getByRole('heading', {name: 'Proof & transparency'})
this.contactAndSupportHeading = page.getByRole('heading', {name: 'Contact & support'})
this.trustAndLegalHeading = page.getByRole('heading', {name: 'Trust & legal'})
this.aboutCompassLink = page.getByRole('link', {name: 'About Compass'})
this.constitutionLink = page.getByRole('link', {name: 'Our constitution'})
this.keyMetricsLink = page.getByRole('link', {name: 'Key metrics & growth'})
this.pressLink = page.getByRole('link', {name: 'Press'})
this.financialTransparencyLink = page.getByRole('link', {name: 'Financial transparency'})
this.contactUsLink = page.getByRole('link', {name: 'Contact us'})
this.helpAndSupportLink = page.getByRole('link', {name: 'Help & support center'})
this.securityLink = page.getByRole('link', {name: 'Security'})
this.termsAndConditionsLink = page.getByRole('link', {name: 'Terms and conditions'})
this.privacyPolicyLink = page.getByRole('link', {name: 'Privacy policy'})
}
async goToOrganizationPage() {
await this.page.goto('/organization')
}
async verifyOrganizationPage() {
await expect(this.page).toHaveURL(/\/organization$/)
await expect(this.pageTitle).toBeVisible()
await expect(this.aboutUsHeading).toBeVisible()
await expect(this.proofAndTransparencyHeading).toBeVisible()
await expect(this.contactAndSupportHeading).toBeVisible()
await expect(this.trustAndLegalHeading).toBeVisible()
await this.verifyOrganizationLinks()
}
async verifyOrganizationLinks() {
await expect(this.aboutCompassLink).toBeVisible()
await expect(this.aboutCompassLink).toHaveAttribute('href', '/about')
await expect(this.constitutionLink).toBeVisible()
await expect(this.constitutionLink).toHaveAttribute('href', '/constitution')
await expect(this.keyMetricsLink).toBeVisible()
await expect(this.keyMetricsLink).toHaveAttribute('href', '/stats')
await expect(this.pressLink).toBeVisible()
await expect(this.pressLink).toHaveAttribute('href', '/press')
await expect(this.financialTransparencyLink).toBeVisible()
await expect(this.financialTransparencyLink).toHaveAttribute('href', '/financials')
await expect(this.contactUsLink).toBeVisible()
await expect(this.contactUsLink).toHaveAttribute('href', '/contact')
await expect(this.helpAndSupportLink).toBeVisible()
await expect(this.helpAndSupportLink).toHaveAttribute('href', '/help')
await expect(this.securityLink).toBeVisible()
await expect(this.securityLink).toHaveAttribute('href', '/security')
await expect(this.termsAndConditionsLink).toBeVisible()
await expect(this.termsAndConditionsLink).toHaveAttribute('href', '/terms')
await expect(this.privacyPolicyLink).toBeVisible()
await expect(this.privacyPolicyLink).toHaveAttribute('href', '/privacy')
}
async clickAboutCompassLink() {
await expect(this.aboutCompassLink).toBeVisible()
await this.aboutCompassLink.click()
}
async clickConstitutionLink() {
await expect(this.constitutionLink).toBeVisible()
await this.constitutionLink.click()
}
async clickKeyMetricsLink() {
await expect(this.keyMetricsLink).toBeVisible()
await this.keyMetricsLink.click()
}
async clickPressLink() {
await expect(this.pressLink).toBeVisible()
await this.pressLink.click()
}
async clickFinancialTransparencyLink() {
await expect(this.financialTransparencyLink).toBeVisible()
await this.financialTransparencyLink.click()
}
async clickContactUsLink() {
await expect(this.contactUsLink).toBeVisible()
await this.contactUsLink.click()
}
async clickHelpAndSupportLink() {
await expect(this.helpAndSupportLink).toBeVisible()
await this.helpAndSupportLink.click()
}
async clickSecurityLink() {
await expect(this.securityLink).toBeVisible()
await this.securityLink.click()
}
async clickTermsAndConditionsLink() {
await expect(this.termsAndConditionsLink).toBeVisible()
await this.termsAndConditionsLink.click()
}
async clickPrivacyPolicyLink() {
await expect(this.privacyPolicyLink).toBeVisible()
await this.privacyPolicyLink.click()
}
}

View File

@@ -0,0 +1,159 @@
import {expect, Locator, Page} from '@playwright/test'
import {LocaleTuple} from 'common/constants'
import {FontsTuple} from 'web/components/font-picker'
export class SettingsPage {
private readonly localePicker: Locator
private readonly measurementSystemToggle: Locator
private readonly themeToggle: Locator
private readonly fontPicker: Locator
private readonly downloadProfileJSONDataButton: Locator
private readonly manageHiddenProfilesButton: Locator
private readonly hiddenProfilesSection: Locator
private readonly directMessagingPreferenceToggle: Locator
private readonly privateInterestSignalsToggle: Locator
private readonly sendVerificationEmailButton: Locator
private readonly verifiedEmailLink: Locator
private readonly changeEmailButton: Locator
private readonly sendPasswordResetButton: Locator
private readonly deleteAccountButton: Locator
private readonly closeButton: Locator
private readonly cancelButton: Locator
private readonly deleteSurveyModal: Locator
private readonly deleteSurveyReasons: Locator
private readonly deleteSurveyDetails: Locator
constructor(public readonly page: Page) {
this.localePicker = page.getByTestId('sidebar-locale-picker')
this.measurementSystemToggle = page.getByTestId('measurement-system-toggle')
this.themeToggle = page.getByTestId('settings-dark-light-toggle')
this.fontPicker = page.getByTestId('settings-font-picker')
this.downloadProfileJSONDataButton = page.getByRole('button', {
name: 'Download all my data (JSON)',
})
this.manageHiddenProfilesButton = page.getByRole('button', {name: 'Manage hidden profiles'})
this.hiddenProfilesSection = page.getByTestId('hidden-profiles')
this.directMessagingPreferenceToggle = page.getByTestId('settings-direct-message-toggle')
this.privateInterestSignalsToggle = page.getByTestId('settings-private-interest-signal-toggle')
this.sendVerificationEmailButton = page.getByRole('button', {name: 'Send verification email'})
this.verifiedEmailLink = page.getByRole('button', {name: 'I verified my email'}) // Need method for this
this.changeEmailButton = page.getByRole('button', {name: 'Change email address'})
this.sendPasswordResetButton = page.getByRole('button', {name: 'Send password reset email'})
this.deleteAccountButton = page.getByRole('button', {name: 'Delete account'})
this.closeButton = page.getByRole('button', {name: 'Close'})
this.cancelButton = page.getByRole('button', {name: 'Cancel'})
this.deleteSurveyModal = page.getByTestId('delete-survey-modal')
this.deleteSurveyReasons = page.getByTestId('delete-account-survey-reasons')
this.deleteSurveyDetails = page.getByRole('textbox')
}
async setLocale(locale: LocaleTuple) {
if (!locale) return
await expect(this.localePicker).toBeVisible()
await this.localePicker.selectOption(locale[0])
}
async toggleMeasurementSystem() {
await expect(this.measurementSystemToggle).toBeVisible()
await this.measurementSystemToggle.click()
}
async toggleDisplayTheme() {
await expect(this.themeToggle).toBeVisible()
await this.themeToggle.click()
}
async setFont(font: FontsTuple) {
if (!font) return
await expect(this.fontPicker).toBeVisible()
await this.fontPicker.selectOption(font[0])
}
async clickdownloadProfileDataButton() {
await expect(this.downloadProfileJSONDataButton).toBeVisible()
await this.downloadProfileJSONDataButton.click()
}
async clickManageHiddenProfilesButton() {
await expect(this.manageHiddenProfilesButton).toBeVisible()
await this.manageHiddenProfilesButton.click()
}
async clickCancelButton() {
await expect(this.cancelButton).toBeVisible()
await this.cancelButton.click()
}
async verifyHiddenProfiles(profiles: string[]) {
await expect(this.hiddenProfilesSection).toBeVisible()
for (let i = 0; i < profiles.length; i++) {
try {
await expect(
this.hiddenProfilesSection.getByRole('link', {name: `${profiles[i]}`}),
).toBeVisible({timeout: 2000})
} catch (error) {
throw new Error(`Profile ${profiles[i]} has not been hidden`)
}
}
}
async unhideProfiles(profile: string) {
await expect(this.hiddenProfilesSection).toBeVisible()
const hiddenProfiles = await this.hiddenProfilesSection.count()
let matchIndex = -1
for (let i = 0; i < hiddenProfiles; i++) {
const target = await this.hiddenProfilesSection.getByRole('link', {name: `${profile}`})
if (target) {
matchIndex = i
}
}
await this.hiddenProfilesSection
.locator('div')
.nth(matchIndex)
.getByRole('button', {name: 'Unhide'})
.click()
}
async toggleDirectMessagingPreferences() {
await expect(this.directMessagingPreferenceToggle).toBeVisible()
await this.directMessagingPreferenceToggle.click()
}
async togglePrivateInterestSignalsPreferences() {
await expect(this.privateInterestSignalsToggle).toBeVisible()
await this.privateInterestSignalsToggle.click()
}
async clickSendVerificationEmailButton() {
await expect(this.sendVerificationEmailButton).toBeVisible()
await this.sendVerificationEmailButton.click()
}
async clickChangeEmailAddressButton() {
await expect(this.changeEmailButton).toBeVisible()
await this.changeEmailButton.click()
}
async clickSendPasswordResetEmailButton() {
await expect(this.sendPasswordResetButton).toBeVisible()
await this.sendPasswordResetButton.click()
}
async clickDeleteAccountButton() {
await expect(this.deleteAccountButton).toBeVisible()
await this.deleteAccountButton.click()
}
async clickCloseButton() {
await expect(this.closeButton).toBeVisible()
await this.closeButton.click()
}
async fillDeleteAccountSurvey(reason: string) {
await expect(this.deleteSurveyModal).toBeVisible()
await expect(this.deleteSurveyReasons).toBeVisible()
await this.deleteSurveyReasons.locator('div').nth(1).click()
await expect(this.deleteSurveyDetails).toBeVisible()
await this.deleteSurveyDetails.fill(reason)
}
}

View File

@@ -0,0 +1,113 @@
import {expect, Locator, Page} from '@playwright/test'
import {
discordLink,
githubRepo,
instagramLink,
redditLink,
stoatLink,
supportEmail,
xLink,
} from 'common/constants'
export class SocialPage {
private readonly pageTitle: Locator
private readonly communityHeading: Locator
private readonly followAndUpdatesHeading: Locator
private readonly developmentHeading: Locator
private readonly contactHeading: Locator
private readonly discordButton: Locator
private readonly redditButton: Locator
private readonly stoatButton: Locator
private readonly xButton: Locator
private readonly instagramButton: Locator
private readonly githubButton: Locator
private readonly emailButton: Locator
constructor(public readonly page: Page) {
this.pageTitle = page.getByRole('heading', {name: 'Socials'})
this.communityHeading = page.getByRole('heading', {name: 'Community'})
this.followAndUpdatesHeading = page.getByRole('heading', {name: 'Follow & Updates'})
this.developmentHeading = page.getByRole('heading', {name: 'Development'})
this.contactHeading = page.getByRole('heading', {name: 'Contact'})
this.discordButton = page.getByRole('link', {name: 'Discord'})
this.redditButton = page.getByRole('link', {name: 'Reddit'})
this.stoatButton = page.getByRole('link', {name: 'Revolt / Stoat'})
this.xButton = page.getByRole('link', {name: 'X'})
this.instagramButton = page.getByRole('link', {name: 'Instagram'})
this.githubButton = page.getByRole('link', {name: 'GitHub'})
this.emailButton = page.getByRole('link', {name: `Email ${supportEmail}`})
}
async goToSocialPage() {
await this.page.goto('/social')
}
async verifySocialPage() {
await expect(this.page).toHaveURL(/\/social$/)
await expect(this.pageTitle).toBeVisible()
await expect(this.communityHeading).toBeVisible()
await expect(this.followAndUpdatesHeading).toBeVisible()
await expect(this.developmentHeading).toBeVisible()
await expect(this.contactHeading).toBeVisible()
await this.verifySocialLinks()
}
async verifySocialLinks() {
await expect(this.discordButton).toBeVisible()
await expect(this.discordButton).toHaveAttribute('href', discordLink)
await expect(this.redditButton).toBeVisible()
await expect(this.redditButton).toHaveAttribute('href', redditLink)
await expect(this.stoatButton).toBeVisible()
await expect(this.stoatButton).toHaveAttribute('href', stoatLink)
await expect(this.xButton).toBeVisible()
await expect(this.xButton).toHaveAttribute('href', xLink)
await expect(this.instagramButton).toBeVisible()
await expect(this.instagramButton).toHaveAttribute('href', instagramLink)
await expect(this.githubButton).toBeVisible()
await expect(this.githubButton).toHaveAttribute('href', githubRepo)
await expect(this.emailButton).toBeVisible()
await expect(this.emailButton).toHaveAttribute('href', `mailto:${supportEmail}`)
}
async clickDiscordButton() {
await expect(this.discordButton).toBeVisible()
await this.discordButton.click()
}
async clickRedditButton() {
await expect(this.redditButton).toBeVisible()
await this.redditButton.click()
}
async clickStoatButton() {
await expect(this.stoatButton).toBeVisible()
await this.stoatButton.click()
}
async clickXButton() {
await expect(this.xButton).toBeVisible()
await this.xButton.click()
}
async clickInstagramButton() {
await expect(this.instagramButton).toBeVisible()
await this.instagramButton.click()
}
async clickGitHubButton() {
await expect(this.githubButton).toBeVisible()
await this.githubButton.click()
}
async clickEmailButton() {
await expect(this.emailButton).toBeVisible()
await this.emailButton.click()
}
}

View File

@@ -1,9 +1,9 @@
import {userInformationFromDb} from '../../utils/databaseUtils' import {userInformationFromDb} from '../../utils/databaseUtils'
import {progressToRequiredForm} from '../utils/testCleanupHelpers'
import {expect, test} from '../fixtures/base' import {expect, test} from '../fixtures/base'
import {registerWithEmail, skipOnboardingHeadToProfile} from '../utils/testCleanupHelpers'
test.describe('when given valid input', () => { test.describe('when given valid input', () => {
test('should successfully complete the onboarding flow', async ({ test('should successfully complete the onboarding flow with email', async ({
homePage, homePage,
onboardingPage, onboardingPage,
signUpPage, signUpPage,
@@ -11,14 +11,7 @@ test.describe('when given valid input', () => {
profilePage, profilePage,
onboardingAccount, onboardingAccount,
}) => { }) => {
console.log( await registerWithEmail(homePage, authPage, onboardingAccount)
`Starting "should successfully complete the onboarding flow" with ${onboardingAccount.username}`,
)
await homePage.gotToHomePage()
await homePage.clickSignUpButton()
await authPage.fillEmailField(onboardingAccount.email)
await authPage.fillPasswordField(onboardingAccount.password)
await authPage.clickSignUpWithEmailButton()
await onboardingPage.clickContinueButton() //First continue await onboardingPage.clickContinueButton() //First continue
await onboardingPage.clickContinueButton() //Second continue await onboardingPage.clickContinueButton() //Second continue
await onboardingPage.clickGetStartedButton() await onboardingPage.clickGetStartedButton()
@@ -225,6 +218,35 @@ test.describe('when given valid input', () => {
) )
}) })
test('should successfully complete the onboarding flow with google account', async ({
homePage,
onboardingPage,
signUpPage,
authPage,
profilePage,
googleAccountOne,
headless,
}) => {
test.skip(headless, 'Google popup auth test requires headed mode')
await homePage.goToRegisterPage()
await authPage.fillPasswordField('') //The test only passes when this is added...something is weird here
await authPage.signInToGoogleAccount(
googleAccountOne.email,
googleAccountOne.display_name,
googleAccountOne.username,
)
await skipOnboardingHeadToProfile(onboardingPage, signUpPage, profilePage, googleAccountOne)
//Verify displayed information is correct
await profilePage.verifyDisplayName(googleAccountOne.display_name)
//Verify database info
const dbInfo = await userInformationFromDb(googleAccountOne)
await expect(dbInfo.user.name).toContain(googleAccountOne.display_name)
await expect(dbInfo.user.username).toContain(googleAccountOne.username)
})
test('should successfully skip the onboarding flow', async ({ test('should successfully skip the onboarding flow', async ({
homePage, homePage,
onboardingPage, onboardingPage,
@@ -233,16 +255,8 @@ test.describe('when given valid input', () => {
profilePage, profilePage,
fakerAccount, fakerAccount,
}) => { }) => {
console.log( await registerWithEmail(homePage, authPage, fakerAccount)
`Starting "should successfully skip the onboarding flow" with ${fakerAccount.username}`, await skipOnboardingHeadToProfile(onboardingPage, signUpPage, profilePage, fakerAccount)
)
await progressToRequiredForm(homePage, authPage, fakerAccount, onboardingPage)
await signUpPage.fillDisplayName(fakerAccount.display_name)
await signUpPage.fillUsername(fakerAccount.username)
await signUpPage.clickNextButton()
await signUpPage.clickNextButton() //Skip optional information
await profilePage.clickCloseButton()
await onboardingPage.clickRefineProfileButton()
//Verify displayed information is correct //Verify displayed information is correct
await profilePage.verifyDisplayName(fakerAccount.display_name) await profilePage.verifyDisplayName(fakerAccount.display_name)
@@ -262,16 +276,8 @@ test.describe('when given valid input', () => {
profilePage, profilePage,
fakerAccount, fakerAccount,
}) => { }) => {
console.log( await registerWithEmail(homePage, authPage, fakerAccount)
`Starting "should successfully enter optional information after completing flow" with ${fakerAccount.username}`, await skipOnboardingHeadToProfile(onboardingPage, signUpPage, profilePage, fakerAccount)
)
await progressToRequiredForm(homePage, authPage, fakerAccount, onboardingPage)
await signUpPage.fillDisplayName(fakerAccount.display_name)
await signUpPage.fillUsername(fakerAccount.username)
await signUpPage.clickNextButton()
await signUpPage.clickNextButton() //Skip optional information
await profilePage.clickCloseButton()
await onboardingPage.clickRefineProfileButton()
await profilePage.clickEditProfileButton() await profilePage.clickEditProfileButton()
await signUpPage.chooseGender(fakerAccount.gender) await signUpPage.chooseGender(fakerAccount.gender)
await signUpPage.fillAge(fakerAccount.age) await signUpPage.fillAge(fakerAccount.age)
@@ -310,10 +316,8 @@ test.describe('when given valid input', () => {
fakerAccount, fakerAccount,
onboardingAccount, onboardingAccount,
}) => { }) => {
console.log( await registerWithEmail(homePage, authPage, fakerAccount)
`Starting "should successfully use the start answering option" with ${fakerAccount.username}`, await onboardingPage.clickSkipOnboardingButton()
)
await progressToRequiredForm(homePage, authPage, fakerAccount, onboardingPage)
await signUpPage.fillDisplayName(fakerAccount.display_name) await signUpPage.fillDisplayName(fakerAccount.display_name)
await signUpPage.fillUsername(fakerAccount.username) await signUpPage.fillUsername(fakerAccount.username)
await signUpPage.clickNextButton() await signUpPage.clickNextButton()
@@ -338,22 +342,15 @@ test.describe('when given valid input', () => {
}) })
test.describe('should successfully complete the onboarding flow after using the back button', () => { test.describe('should successfully complete the onboarding flow after using the back button', () => {
test.beforeEach(async ({homePage, authPage, fakerAccount}) => {
console.log(`Before each with ${fakerAccount.username}`)
await homePage.gotToHomePage()
await homePage.clickSignUpButton()
await authPage.fillEmailField(fakerAccount.email)
await authPage.fillPasswordField(fakerAccount.password)
await authPage.clickSignUpWithEmailButton()
})
test("the first time it's an option", async ({ test("the first time it's an option", async ({
homePage,
authPage,
onboardingPage, onboardingPage,
signUpPage, signUpPage,
profilePage, profilePage,
fakerAccount, fakerAccount,
}) => { }) => {
console.log(`Starting "the first time its an option" with ${fakerAccount.username}`) await registerWithEmail(homePage, authPage, fakerAccount)
await onboardingPage.clickContinueButton() await onboardingPage.clickContinueButton()
await onboardingPage.clickBackButton() await onboardingPage.clickBackButton()
await onboardingPage.clickContinueButton() await onboardingPage.clickContinueButton()
@@ -377,12 +374,14 @@ test.describe('when given valid input', () => {
}) })
test("the second time it's an option", async ({ test("the second time it's an option", async ({
homePage,
authPage,
onboardingPage, onboardingPage,
signUpPage, signUpPage,
profilePage, profilePage,
fakerAccount, fakerAccount,
}) => { }) => {
console.log(`Starting "the second time its an option" with ${fakerAccount.username}`) await registerWithEmail(homePage, authPage, fakerAccount)
await onboardingPage.clickContinueButton() await onboardingPage.clickContinueButton()
await onboardingPage.clickContinueButton() await onboardingPage.clickContinueButton()
await onboardingPage.clickBackButton() await onboardingPage.clickBackButton()
@@ -407,6 +406,6 @@ test.describe('when given valid input', () => {
}) })
}) })
test.describe('when an error occurs', () => { // test.describe('when an error occurs', () => {
test('placeholder', async () => {}) // test('placeholder', async ({}) => {})
}) // })

View File

@@ -1,9 +0,0 @@
import {expect} from '@playwright/test'
import {test} from '../fixtures/signInFixture'
test('should be logged in and see settings page', async ({authenticatedPage}) => {
await authenticatedPage.goto('/settings')
await expect(authenticatedPage.getByRole('heading', {name: 'Theme'})).toBeVisible()
})

View File

@@ -0,0 +1,108 @@
import {userInformationFromDb} from '../../utils/databaseUtils'
import {seedUser} from '../../utils/seedDatabase'
import {expect, test} from '../fixtures/signInFixture'
import {testAccounts} from '../utils/accountInformation'
import {
deleteProfileFromSettings,
registerWithEmail,
signinWithEmail,
skipOnboardingHeadToProfile,
} from '../utils/testCleanupHelpers'
//Seed the account
test.beforeAll(async () => {
const dev_1_Account = testAccounts.dev_one_account()
try {
await seedUser(
dev_1_Account.email,
dev_1_Account.password,
undefined,
dev_1_Account.display_name,
dev_1_Account.username,
)
} catch (_e) {
console.log('User already exists for signinFixture', dev_1_Account.email)
}
})
test.describe('when given valid input', () => {
test('should be able to sign in to an available account', async ({
homePage,
authPage,
dev_one_account,
}) => {
await signinWithEmail(homePage, authPage, dev_one_account)
await homePage.goToHomePage()
await homePage.verifySignedInHomePage(dev_one_account.display_name)
})
test('should successfully delete an account created via email and password', async ({
homePage,
onboardingPage,
signUpPage,
authPage,
profilePage,
settingsPage,
fakerAccount,
}) => {
await registerWithEmail(homePage, authPage, fakerAccount)
await skipOnboardingHeadToProfile(onboardingPage, signUpPage, profilePage, fakerAccount)
//Verify displayed information is correct
await profilePage.verifyDisplayName(fakerAccount.display_name)
//Verify database info
const dbInfo = await userInformationFromDb(fakerAccount)
await expect(dbInfo.user.name).toContain(fakerAccount.display_name)
await expect(dbInfo.user.username).toContain(fakerAccount.username)
await deleteProfileFromSettings(homePage, settingsPage)
})
test('should successfully delete an account created via google auth', async ({
homePage,
onboardingPage,
signUpPage,
authPage,
profilePage,
settingsPage,
googleAccountTwo,
headless,
}) => {
test.skip(headless, 'Google popup auth test requires headed mode')
await homePage.goToRegisterPage()
await authPage.fillPasswordField('') //The test only passes when this is added...something is weird here
await authPage.signInToGoogleAccount(
googleAccountTwo.email,
googleAccountTwo.display_name,
googleAccountTwo.username,
)
await skipOnboardingHeadToProfile(onboardingPage, signUpPage, profilePage, googleAccountTwo)
//Verify displayed information is correct
await profilePage.verifyDisplayName(googleAccountTwo.display_name)
//Verify database info
const dbInfo = await userInformationFromDb(googleAccountTwo)
await expect(dbInfo.user.name).toContain(googleAccountTwo.display_name)
await expect(dbInfo.user.username).toContain(googleAccountTwo.username)
await deleteProfileFromSettings(homePage, settingsPage)
})
})
test.describe('when given invalid input', () => {
test('should not be able to sign in to an available account', async ({
homePage,
authPage,
dev_one_account,
page,
}) => {
await signinWithEmail(homePage, authPage, dev_one_account.email, 'ThisPassword')
await expect(
page.getByText('Failed to sign in with your email and password', {exact: true}),
).toBeVisible()
})
})

View File

@@ -1,9 +1,9 @@
import {expect, test} from '../fixtures/base' import {expect, test} from '../fixtures/base'
import {progressToRequiredForm} from '../utils/testCleanupHelpers' import {registerWithEmail} from '../utils/testCleanupHelpers'
test.describe('when given valid input', () => { // test.describe('when given valid input', () => {
test('placeholder', async () => {}) // test('placeholder', async () => {})
}) // })
test.describe('when an error occurs', () => { test.describe('when an error occurs', () => {
test('should disable the button "Next" when the display name field is empty', async ({ test('should disable the button "Next" when the display name field is empty', async ({
@@ -13,7 +13,8 @@ test.describe('when an error occurs', () => {
onboardingPage, onboardingPage,
signUpPage, signUpPage,
}) => { }) => {
await progressToRequiredForm(homePage, authPage, specAccount, onboardingPage) await registerWithEmail(homePage, authPage, specAccount)
await onboardingPage.clickSkipOnboardingButton()
await signUpPage.fillDisplayName('') await signUpPage.fillDisplayName('')
await signUpPage.fillUsername(specAccount.username) await signUpPage.fillUsername(specAccount.username)
await signUpPage.verifyDisplayNameError() await signUpPage.verifyDisplayNameError()
@@ -27,7 +28,8 @@ test.describe('when an error occurs', () => {
onboardingPage, onboardingPage,
signUpPage, signUpPage,
}) => { }) => {
await progressToRequiredForm(homePage, authPage, specAccount, onboardingPage) await registerWithEmail(homePage, authPage, specAccount)
await onboardingPage.clickSkipOnboardingButton()
await signUpPage.fillDisplayName(specAccount.display_name) await signUpPage.fillDisplayName(specAccount.display_name)
await signUpPage.fillUsername('') await signUpPage.fillUsername('')
await signUpPage.verifyUsernameError() await signUpPage.verifyUsernameError()

View File

@@ -102,7 +102,9 @@ type AccountConfig = {
spec_account: () => UserAccountInformation spec_account: () => UserAccountInformation
dev_one_account: () => UserAccountInformation dev_one_account: () => UserAccountInformation
dev_two_account: () => UserAccountInformation dev_two_account: () => UserAccountInformation
account_all_info: () => UserAccountInformation google_account_one: () => UserAccountInformation
google_account_two: () => UserAccountInformation
email_account_all_info: () => UserAccountInformation
} }
export const testAccounts: AccountConfig = { export const testAccounts: AccountConfig = {
@@ -137,10 +139,10 @@ export const testAccounts: AccountConfig = {
dev_one_account: () => { dev_one_account: () => {
const id = crypto.randomUUID().slice(0, 6) const id = crypto.randomUUID().slice(0, 6)
return { return {
email: `dev_1_${id}@compass.com`, email: `dev_1@compass.com`,
password: 'dev_1Password', password: 'dev_1Password',
display_name: 'Dev1.Compass', display_name: 'Dev1.Compass',
username: `Dev1.Connections_${id}`, username: `Dev1.Connections`,
} }
}, },
@@ -150,11 +152,31 @@ export const testAccounts: AccountConfig = {
email: 'dev_2@compass.com', email: 'dev_2@compass.com',
password: 'dev_2Password', password: 'dev_2Password',
display_name: 'Dev2.Compass', display_name: 'Dev2.Compass',
username: `Dev2.Connections_${id}`, username: `Dev2.Connections_`,
} }
}, },
account_all_info: () => { google_account_one: () => {
const id = crypto.randomUUID().slice(0, 6)
return {
email: `g1_compass_${id}@gmail.com`,
password: 'G_oneCompassTest',
display_name: 'Google_one_Compass',
username: `G1_Connect_${id}`,
}
},
google_account_two: () => {
const id = crypto.randomUUID().slice(0, 6)
return {
email: `g2_compass_${id}@gmail.com`,
password: 'G_twoCompassTest',
display_name: 'Google_two_Compass',
username: `G2_Connect_${id}`,
}
},
email_account_all_info: () => {
const id = crypto.randomUUID().slice(0, 6) const id = crypto.randomUUID().slice(0, 6)
return { return {
// Use a non-real TLD like @test.compass to make it obvious these are test accounts and prevent accidental emails // Use a non-real TLD like @test.compass to make it obvious these are test accounts and prevent accidental emails

View File

@@ -1,18 +1,31 @@
import {deleteFromDb} from '../../utils/databaseUtils' import {deleteFromDb} from '../../utils/databaseUtils'
import {deleteAccount, firebaseLogin} from '../../utils/firebaseUtils' import {deleteAccount, firebaseLoginEmailPassword} from '../../utils/firebaseUtils'
import {UserAccountInformation} from './accountInformation'
import {AuthObject} from './networkUtils'
export async function deleteUser(email: string, password: string) { type AuthType = 'Email/Password' | 'Google'
export async function deleteUser(
authType: AuthType,
account?: UserAccountInformation,
authInfo?: AuthObject,
) {
try { try {
const loginInfo = await firebaseLogin(email, password) let loginInfo
await deleteAccount(loginInfo) if (authType === 'Email/Password') {
await deleteFromDb(loginInfo.data.localId) loginInfo = await firebaseLoginEmailPassword(account?.email, account?.password)
await deleteAccount(loginInfo?.data.idToken)
await deleteFromDb(loginInfo?.data.localId)
} else if (authType === 'Google' && authInfo) {
await deleteAccount(authInfo.idToken)
await deleteFromDb(authInfo.localId)
}
} catch (err: any) { } catch (err: any) {
// Skip deletion if user doesn't exist or other auth errors occur // Skip deletion if user doesn't exist or other auth errors occur
if ( if (
err.response?.status === 400 || err.response?.status === 400 ||
err.response?.data?.error?.message?.includes('EMAIL_NOT_FOUND') err.response?.data?.error?.message?.includes('EMAIL_NOT_FOUND')
) { ) {
console.log(`Email not found, skipping user deletion for ${email}`) console.log(`Email not found, skipping user deletion for ${account?.email}`)
return return
} }
console.log(err) console.log(err)

View File

@@ -0,0 +1,25 @@
import {Page} from '@playwright/test'
export type AuthObject = {
idToken: string
localId: string
}
export async function getAuthAccountInfo(page: Page): Promise<() => AuthObject> {
let accountIdTokenAndLocalId: AuthObject | undefined
await page.route('**/accounts:signInWithIdp**', async (route) => {
const response = await route.fetch()
const body = await response.json()
accountIdTokenAndLocalId = {idToken: body.idToken, localId: body.localId}
await route.fulfill({response})
})
return () => {
if (!accountIdTokenAndLocalId) {
console.log('Sign-in was never intercepted — test may have been skipped')
return undefined as unknown as AuthObject
}
return accountIdTokenAndLocalId
}
}

View File

@@ -1,18 +1,64 @@
import {AuthPage} from '../pages/AuthPage' import {AuthPage} from '../pages/AuthPage'
import {HomePage} from '../pages/homePage' import {HomePage} from '../pages/homePage'
import {OnboardingPage} from '../pages/onboardingPage' import { OnboardingPage } from '../pages/onboardingPage'
import { ProfilePage } from '../pages/profilePage'
import { SettingsPage } from '../pages/settingsPage'
import { SignUpPage } from '../pages/signUpPage'
import {UserAccountInformation} from '../utils/accountInformation' import {UserAccountInformation} from '../utils/accountInformation'
export async function progressToRequiredForm( export async function registerWithEmail(
homePage: HomePage, homePage: HomePage,
authPage: AuthPage, authPage: AuthPage,
account: UserAccountInformation, account: UserAccountInformation,
onboardingPage: OnboardingPage,
) { ) {
await homePage.gotToHomePage() await homePage.goToRegisterPage()
await homePage.clickSignUpButton()
await authPage.fillEmailField(account.email) await authPage.fillEmailField(account.email)
await authPage.fillPasswordField(account.password) await authPage.fillPasswordField(account.password)
await authPage.clickSignUpWithEmailButton() await authPage.clickSignUpWithEmailButton()
await onboardingPage.clickSkipOnboardingButton() }
export async function signinWithEmail(
homePage: HomePage,
authPage: AuthPage,
accountOrEmail: UserAccountInformation | string,
password?: string,
) {
const email = typeof accountOrEmail === 'string' ? accountOrEmail : accountOrEmail.email
const resolvedPassword = typeof accountOrEmail === 'string' ? password : accountOrEmail.password
if (!email || !resolvedPassword) {
throw new Error('Provide either an `account` or `email` and `password`.')
}
await homePage.goToSigninPage()
await authPage.fillEmailField(email)
await authPage.fillPasswordField(resolvedPassword)
await authPage.clickSignInWithEmailButton()
}
export async function skipOnboardingHeadToProfile(
onboardingPage: OnboardingPage,
signUpPage: SignUpPage,
profilePage: ProfilePage,
account: UserAccountInformation,
) {
await onboardingPage.clickSkipOnboardingButton()
await signUpPage.fillDisplayName(account.display_name)
await signUpPage.fillUsername(account.username)
await signUpPage.clickNextButton()
await signUpPage.clickNextButton()
await profilePage.clickCloseButton()
await onboardingPage.clickRefineProfileButton()
}
export async function deleteProfileFromSettings(
homePage: HomePage,
settingsPage: SettingsPage,
) {
await homePage.clickSettingsLink()
await settingsPage.clickDeleteAccountButton()
await settingsPage.fillDeleteAccountSurvey('Delete me')
await settingsPage.clickDeleteAccountButton()
await homePage.verifyHomePageLinks()
} }

View File

@@ -67,6 +67,7 @@ export function ConnectionPreferencesSettings() {
</div> </div>
</div> </div>
<SwitchSetting <SwitchSetting
testId="settings-direct-message-toggle"
checked={allowDirectMessaging} checked={allowDirectMessaging}
onChange={handleDirectMessagingChange} onChange={handleDirectMessagingChange}
disabled={isUpdating} disabled={isUpdating}
@@ -87,6 +88,7 @@ export function ConnectionPreferencesSettings() {
</div> </div>
</div> </div>
<SwitchSetting <SwitchSetting
testId="settings-private-interest-signal-toggle"
checked={allowInterestIndicating} checked={allowInterestIndicating}
onChange={handleInterestIndicatingChange} onChange={handleInterestIndicatingChange}
disabled={isUpdating} disabled={isUpdating}

View File

@@ -2,6 +2,7 @@ import {QuestionMarkCircleIcon} from '@heroicons/react/24/outline'
import {DisplayUser} from 'common/api/user-types' import {DisplayUser} from 'common/api/user-types'
import {FilterFields} from 'common/filters' import {FilterFields} from 'common/filters'
import {Profile} from 'common/profiles/profile' import {Profile} from 'common/profiles/profile'
import {debounce as debounceFn} from 'lodash'
import {forwardRef, ReactElement, useEffect, useRef, useState} from 'react' import {forwardRef, ReactElement, useEffect, useRef, useState} from 'react'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import {IoFilterSharp} from 'react-icons/io5' import {IoFilterSharp} from 'react-icons/io5'
@@ -180,6 +181,22 @@ export const Search = forwardRef<
const isClearedFilters = useIsClearedFilters(filters) const isClearedFilters = useIsClearedFilters(filters)
const choices = useChoicesContext() const choices = useChoicesContext()
const [keywordInput, setKeywordInput] = useState(filters.name ?? '')
const debouncedUpdateFilter = useRef(
debounceFn((value: string) => {
updateFilter({name: value || undefined})
}, 500),
).current
useEffect(() => {
debouncedUpdateFilter(keywordInput)
}, [keywordInput, debouncedUpdateFilter])
useEffect(() => {
setKeywordInput(filters.name ?? '')
}, [filters.name])
useEffect(() => { useEffect(() => {
if (isHolding) return if (isHolding) return
@@ -214,11 +231,11 @@ export const Search = forwardRef<
<Row className={'mb-2 justify-between gap-2'}> <Row className={'mb-2 justify-between gap-2'}>
<Input <Input
ref={ref} ref={ref}
value={filters.name ?? ''} value={keywordInput}
placeholder={placeholder} placeholder={placeholder}
className={'w-full max-w-xs'} className={'w-full max-w-xs'}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => { onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
updateFilter({name: e.target.value || undefined}) setKeywordInput(e.target.value)
}} }}
/> />

View File

@@ -19,6 +19,7 @@ export function FontPicker(props: {className?: string} = {}) {
return ( return (
<select <select
data-testid="settings-font-picker"
id="font-picker" id="font-picker"
value={font} value={font}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setFont(e.target.value as FontOption)} onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setFont(e.target.value as FontOption)}
@@ -35,3 +36,8 @@ export function FontPicker(props: {className?: string} = {}) {
</select> </select>
) )
} }
//Exported types for test files to use when referencing the keys of the choices objects
export type FontsTuple = {
[K in keyof typeof EN_TRANSLATIONS]: [K, (typeof EN_TRANSLATIONS)[K]]
}[keyof typeof EN_TRANSLATIONS]

View File

@@ -18,6 +18,7 @@ export default function MeasurementSystemToggle(props: {className?: string}) {
</span> </span>
<Switch <Switch
data-testid="measurement-system-toggle"
checked={isEnabled} checked={isEnabled}
onChange={(enabled: boolean) => setMeasurementSystem(enabled ? 'metric' : 'imperial')} onChange={(enabled: boolean) => setMeasurementSystem(enabled ? 'metric' : 'imperial')}
className={clsx( className={clsx(

View File

@@ -22,7 +22,7 @@ export function ProfileSummary(props: {user: User; className?: string}) {
<div className="w-2 shrink" /> <div className="w-2 shrink" />
<Avatar avatarUrl={profile?.pinned_url ?? ''} username={user.username} noLink /> <Avatar avatarUrl={profile?.pinned_url ?? ''} username={user.username} noLink />
<div className="mr-1 w-2 shrink-[2]" /> <div className="mr-1 w-2 shrink-[2]" />
<div className="shrink-0 grow"> <div className="shrink-0 grow" data-testid="sidebar-username">
<div className="group-hover:text-primary-700">{user.name}</div> <div className="group-hover:text-primary-700">{user.name}</div>
</div> </div>
<div className="w-2 shrink" /> <div className="w-2 shrink" />

View File

@@ -54,7 +54,6 @@ export function SidebarItem(props: {item: Item; currentPage?: string}) {
return ( return (
<Link <Link
href={item.href} href={item.href}
data-testid={`sidebar-${item.href?.replace('/', '')}`}
aria-current={isCurrentPage ? 'page' : undefined} aria-current={isCurrentPage ? 'page' : undefined}
onClick={onClick} onClick={onClick}
className={sidebarClass} className={sidebarClass}

View File

@@ -41,6 +41,7 @@ export default function Sidebar(props: {
<nav <nav
id="main-navigation" id="main-navigation"
aria-label="Sidebar" aria-label="Sidebar"
data-testid="sidebar"
className={clsx( className={clsx(
'flex flex-col h-[calc(100dvh-var(--hloss))] mb-[calc(var(--bnh))] mt-[calc(var(--tnh))]', 'flex flex-col h-[calc(100dvh-var(--hloss))] mb-[calc(var(--bnh))] mt-[calc(var(--tnh))]',
className, className,
@@ -119,7 +120,6 @@ export const SignUpButton = (props: {
return ( return (
<Button <Button
data-testid="side-bar-sign-up-button"
color={color ?? 'gradient'} color={color ?? 'gradient'}
size={size ?? 'xl'} size={size ?? 'xl'}
onClick={startSignup} onClick={startSignup}

View File

@@ -85,7 +85,7 @@ export const ViewProfileCardButton = (props: {
src={src} src={src}
width={width} width={width}
height={height} height={height}
alt={t('profile_card.loading', 'Loading your card, it may take a few seconds...')} alt={t('profile_card.loading', `${user.username}'s profile card`)}
className={'rounded-2xl'} className={'rounded-2xl'}
/> />
<ShareProfileButtons <ShareProfileButtons

View File

@@ -141,7 +141,7 @@ export function DeleteAccountSurveyModal() {
onSubmitWithSuccess={handleDeleteAccount} onSubmitWithSuccess={handleDeleteAccount}
disabled={false} disabled={false}
> >
<Col className="gap-4"> <Col className="gap-4" data-testid="delete-survey-modal">
<Title>{t('delete_survey.title', 'Sorry to see you go')}</Title> <Title>{t('delete_survey.title', 'Sorry to see you go')}</Title>
<div> <div>
@@ -157,7 +157,7 @@ export function DeleteAccountSurveyModal() {
{t('delete_survey.reason_label', 'Why are you deleting your account?')} {t('delete_survey.reason_label', 'Why are you deleting your account?')}
</RadioGroup.Label> </RadioGroup.Label>
<div className="space-y-2 mt-2"> <div className="space-y-2 mt-2" data-testid="delete-account-survey-reasons">
{Object.entries(reasonsMap).map(([key, value]) => ( {Object.entries(reasonsMap).map(([key, value]) => (
<RadioGroup.Option <RadioGroup.Option
key={key} key={key}

View File

@@ -49,7 +49,10 @@ export function HiddenProfilesModal(props: {open: boolean; setOpen: (open: boole
{t('settings.hidden_profiles.title', "Profiles you've hidden")} {t('settings.hidden_profiles.title', "Profiles you've hidden")}
</Title> </Title>
{hiddenProfiles && hiddenProfiles.length > 0 && ( {hiddenProfiles && hiddenProfiles.length > 0 && (
<Col className={clsx('divide-y divide-canvas-300 w-full pr-4', SCROLLABLE_MODAL_CLASS)}> <Col
className={clsx('divide-y divide-canvas-300 w-full pr-4', SCROLLABLE_MODAL_CLASS)}
data-testid="hidden-profiles"
>
{hiddenProfiles.map((u) => ( {hiddenProfiles.map((u) => (
<Row key={u.id} className="items-center justify-between py-2 gap-2"> <Row key={u.id} className="items-center justify-between py-2 gap-2">
<Link className="w-full rounded-md hover:bg-canvas-100 p-2" href={'/' + u.username}> <Link className="w-full rounded-md hover:bg-canvas-100 p-2" href={'/' + u.username}>

View File

@@ -3,13 +3,13 @@ export function SkipLink() {
<> <>
<a <a
href="#main-content" href="#main-content"
className="absolute -top-10 left-4 z-50 bg-primary-500 px-4 py-2 text-white transition-all focus:top-4" className="absolute -top-10 left-4 z-50 bg-canvas-100 px-4 py-2 transition-all focus:top-4"
> >
Skip to main content Skip to main content
</a> </a>
<a <a
href="#main-navigation" href="#main-navigation"
className="absolute -top-10 left-4 z-50 bg-primary-500 px-4 py-2 text-white transition-all focus:top-4" className="absolute -top-10 left-4 z-50 bg-canvas-100 px-4 py-2 transition-all focus:top-4"
> >
Skip to navigation Skip to navigation
</a> </a>

View File

@@ -9,10 +9,11 @@ export const SwitchSetting = (props: {
label?: 'Web' | 'Email' | 'Mobile' label?: 'Web' | 'Email' | 'Mobile'
disabled: boolean disabled: boolean
colorMode?: ToggleColorMode colorMode?: ToggleColorMode
testId?: string
}) => { }) => {
const {colorMode, checked, onChange, label, disabled} = props const {colorMode, checked, onChange, label, disabled, testId} = props
return ( return (
<Switch.Group as="div" className="flex items-center gap-3"> <Switch.Group as="div" className="flex items-center gap-3" data-testid={testId}>
<ShortToggle colorMode={colorMode} on={checked} setOn={onChange} disabled={disabled} /> <ShortToggle colorMode={colorMode} on={checked} setOn={onChange} disabled={disabled} />
<Switch.Label <Switch.Label
className={clsx( className={clsx(

View File

@@ -31,7 +31,12 @@ export default function ThemeIcon(props: {className?: string}) {
</> </>
) )
return ( return (
<button onClick={toggleTheme} className={'w-fit'}> <button
type={'button'}
onClick={toggleTheme}
className={'w-fit'}
data-testid="settings-dark-light-toggle"
>
<Row className="items-center gap-1 border-2 border-gray-500 rounded-full p-1 w-fit mx-2 px-3 hover:bg-canvas-100"> <Row className="items-center gap-1 border-2 border-gray-500 rounded-full p-1 w-fit mx-2 px-3 hover:bg-canvas-100">
{icon} {icon}
{children} {children}

View File

@@ -10,8 +10,9 @@ export default function ShortToggle(props: {
className?: string className?: string
colorMode?: ToggleColorMode colorMode?: ToggleColorMode
size?: 'sm' size?: 'sm'
testId?: string
}) { }) {
const {on, size, setOn, disabled, className, colorMode = 'primary'} = props const {on, size, setOn, disabled, className, colorMode = 'primary', testId} = props
const toggleBaseClasses = const toggleBaseClasses =
'group relative inline-flex flex-shrink-0 rounded-full border-2 border-transparent ring-offset-2 transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2' 'group relative inline-flex flex-shrink-0 rounded-full border-2 border-transparent ring-offset-2 transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2'
@@ -34,6 +35,7 @@ export default function ShortToggle(props: {
return ( return (
<Switch <Switch
data-testid={testId}
disabled={disabled} disabled={disabled}
checked={on} checked={on}
onChange={setOn} onChange={setOn}

View File

@@ -6,13 +6,15 @@ import {
} from 'common/profiles/profile' } from 'common/profiles/profile'
import {Row} from 'common/supabase/utils' import {Row} from 'common/supabase/utils'
import {User} from 'common/user' import {User} from 'common/user'
import {useEffect} from 'react' import {createContext, ReactNode, useContext, useEffect} from 'react'
import {usePersistentInMemoryState} from 'web/hooks/use-persistent-in-memory-state' import {usePersistentInMemoryState} from 'web/hooks/use-persistent-in-memory-state'
import {usePersistentLocalState} from 'web/hooks/use-persistent-local-state' import {usePersistentLocalState} from 'web/hooks/use-persistent-local-state'
import {useUser} from 'web/hooks/use-user' import {useUser} from 'web/hooks/use-user'
import {db} from 'web/lib/supabase/db' import {db} from 'web/lib/supabase/db'
export const useProfile = () => { type OwnProfile = (Row<'profiles'> & {user: User}) | null | undefined
const useOwnProfile = (): OwnProfile => {
const user = useUser() const user = useUser()
const [profile, setProfile] = usePersistentLocalState<Row<'profiles'> | undefined | null>( const [profile, setProfile] = usePersistentLocalState<Row<'profiles'> | undefined | null>(
undefined, undefined,
@@ -20,13 +22,11 @@ export const useProfile = () => {
) )
const refreshProfile = () => { const refreshProfile = () => {
if (user) { if (!user?.id) return
// logger.debug('Refreshing profile in useProfile for', user?.username, profile); debug('Refreshing own profile for', user.username)
getProfileRowWithFrontendSupabase(user.id, db).then((profile) => { getProfileRowWithFrontendSupabase(user.id, db).then((p) => {
if (!profile) setProfile(null) setProfile(p ?? null)
else setProfile(profile) })
})
}
} }
useEffect(() => { useEffect(() => {
@@ -36,6 +36,21 @@ export const useProfile = () => {
return user && profile ? {...profile, user} : profile === null ? null : undefined return user && profile ? {...profile, user} : profile === null ? null : undefined
} }
const MISSING = Symbol('missing')
const ProfileContext = createContext<OwnProfile | typeof MISSING>(MISSING)
export const useProfile = () => {
const ctx = useContext(ProfileContext)
if (ctx === MISSING) throw new Error('useProfile must be used within a ProfileProvider')
return ctx as OwnProfile
}
export const ProfileProvider = ({children}: {children: ReactNode}) => {
const profile = useOwnProfile()
return <ProfileContext.Provider value={profile}>{children}</ProfileContext.Provider>
}
export const useProfileByUser = (user: User | undefined) => { export const useProfileByUser = (user: User | undefined) => {
const userId = user?.id const userId = user?.id
const [profile, setProfile] = usePersistentInMemoryState<Profile | undefined | null>( const [profile, setProfile] = usePersistentInMemoryState<Profile | undefined | null>(

View File

@@ -1,7 +1,7 @@
import {PushNotifications} from '@capacitor/push-notifications' import {PushNotifications} from '@capacitor/push-notifications'
import {debug} from 'common/logger' import {debug} from 'common/logger'
import {useRouter} from 'next/router'
import {useEffect} from 'react' import {useEffect} from 'react'
import toast from 'react-hot-toast'
import {useUser} from 'web/hooks/use-user' import {useUser} from 'web/hooks/use-user'
import {api} from 'web/lib/api' import {api} from 'web/lib/api'
import {isAndroidApp} from 'web/lib/util/webview' import {isAndroidApp} from 'web/lib/util/webview'
@@ -9,7 +9,6 @@ import {isAndroidApp} from 'web/lib/util/webview'
export default function AndroidPush() { export default function AndroidPush() {
const user = useUser() // authenticated user const user = useUser() // authenticated user
const isAndroid = isAndroidApp() const isAndroid = isAndroidApp()
const router = useRouter()
useEffect(() => { useEffect(() => {
if (!user?.id || !isAndroid) return if (!user?.id || !isAndroid) return
debug('AndroidPush', user) debug('AndroidPush', user)
@@ -36,12 +35,14 @@ export default function AndroidPush() {
}) })
PushNotifications.addListener('pushNotificationReceived', (notif) => { PushNotifications.addListener('pushNotificationReceived', (notif) => {
debug('Push received', notif) console.debug('Push received', notif, window.location.pathname)
const url = notif?.data?.url const endpoint = notif?.data?.endpoint as string
if (url) { if (!endpoint) return
router.push(url) if (!endpoint.startsWith('/messages/')) return
window.location.href = url if (endpoint === window.location.pathname) return
} const author = notif?.title
const message = notif?.body
toast.success(`${author}: "${message}"`)
}) })
}, [user?.id, isAndroid]) }, [user?.id, isAndroid])

View File

@@ -51,6 +51,7 @@ const nextConfig: NextConfig = {
remotePatterns: [ remotePatterns: [
{hostname: 'martinbraquet.com'}, {hostname: 'martinbraquet.com'},
{hostname: 'compassmeet.com'}, {hostname: 'compassmeet.com'},
{hostname: 'www.compassmeet.com'},
{hostname: 'lh3.googleusercontent.com'}, {hostname: 'lh3.googleusercontent.com'},
{hostname: 'i.imgur.com'}, {hostname: 'i.imgur.com'},
{hostname: 'firebasestorage.googleapis.com'}, {hostname: 'firebasestorage.googleapis.com'},

View File

@@ -5,10 +5,12 @@ import {App} from '@capacitor/app'
import {Capacitor} from '@capacitor/core' import {Capacitor} from '@capacitor/core'
import {Keyboard} from '@capacitor/keyboard' import {Keyboard} from '@capacitor/keyboard'
import {StatusBar} from '@capacitor/status-bar' import {StatusBar} from '@capacitor/status-bar'
import * as Sentry from '@sentry/node'
import clsx from 'clsx' import clsx from 'clsx'
import {DEPLOYED_WEB_URL} from 'common/envs/constants' import {DEPLOYED_WEB_URL} from 'common/envs/constants'
import {IS_VERCEL, PNG_FAVICON} from 'common/hosting/constants' import {IS_VERCEL, PNG_FAVICON} from 'common/hosting/constants'
import {debug} from 'common/logger' import {debug} from 'common/logger'
import {isUrl} from 'common/parsing'
import type {AppProps} from 'next/app' import type {AppProps} from 'next/app'
import {Major_Mono_Display} from 'next/font/google' import {Major_Mono_Display} from 'next/font/google'
import Head from 'next/head' import Head from 'next/head'
@@ -25,6 +27,7 @@ import {useFontPreferenceManager} from 'web/hooks/use-font-preference'
import {useHasLoaded} from 'web/hooks/use-has-loaded' import {useHasLoaded} from 'web/hooks/use-has-loaded'
import {HiddenProfilesProvider} from 'web/hooks/use-hidden-profiles' import {HiddenProfilesProvider} from 'web/hooks/use-hidden-profiles'
import {PinnedQuestionIdsProvider} from 'web/hooks/use-pinned-question-ids' import {PinnedQuestionIdsProvider} from 'web/hooks/use-pinned-question-ids'
import {ProfileProvider} from 'web/hooks/use-profile'
import {updateStatusBar} from 'web/hooks/use-theme' import {updateStatusBar} from 'web/hooks/use-theme'
import {updateBackendLocale} from 'web/lib/api' import {updateBackendLocale} from 'web/lib/api'
import {DAYJS_LOCALE_IMPORTS, registerDatePickerLocale} from 'web/lib/dayjs' import {DAYJS_LOCALE_IMPORTS, registerDatePickerLocale} from 'web/lib/dayjs'
@@ -162,10 +165,23 @@ function MyApp(props: AppProps<PageProps>) {
const link = window.AndroidBridge?.getPendingDeepLink?.() const link = window.AndroidBridge?.getPendingDeepLink?.()
if (link) { if (link) {
handleAppLink({url: link, endpoint: new URL(link).pathname}) handleAppLink({endpoint: isUrl(link) ? new URL(link).pathname : link})
} }
}, []) }, [])
useEffect(() => {
const fetchAppInfo = async () => {
if (!Capacitor.isNativePlatform()) return
const appInfo = await App.getInfo().catch((e) => debug('Could not load Android app info:', e))
const appVersion = appInfo?.version
if (appVersion) {
Sentry.setTag('androidApp.version', appVersion)
Sentry.setTag('androidApp.buildNumber', appInfo?.build)
}
}
fetchAppInfo()
}, [])
const title = 'Compass' const title = 'Compass'
const description = 'The platform for intentional connections' const description = 'The platform for intentional connections'
@@ -208,15 +224,17 @@ function MyApp(props: AppProps<PageProps>) {
<I18nContext.Provider value={{locale, setLocale}}> <I18nContext.Provider value={{locale, setLocale}}>
<ErrorBoundary> <ErrorBoundary>
<AuthProvider serverUser={pageProps.auth}> <AuthProvider serverUser={pageProps.auth}>
<ChoicesProvider> <ProfileProvider>
<PinnedQuestionIdsProvider> <ChoicesProvider>
<HiddenProfilesProvider> <PinnedQuestionIdsProvider>
<WebPush /> <HiddenProfilesProvider>
<AndroidPush /> <WebPush />
<Component {...pageProps} /> <AndroidPush />
</HiddenProfilesProvider> <Component {...pageProps} />
</PinnedQuestionIdsProvider> </HiddenProfilesProvider>
</ChoicesProvider> </PinnedQuestionIdsProvider>
</ChoicesProvider>
</ProfileProvider>
</AuthProvider> </AuthProvider>
</ErrorBoundary> </ErrorBoundary>
</I18nContext.Provider> </I18nContext.Provider>

View File

@@ -6,6 +6,7 @@
"package_name": "com.compassconnections.app", "package_name": "com.compassconnections.app",
"sha256_cert_fingerprints": [ "sha256_cert_fingerprints": [
"29:61:EB:C6:DE:D4:E3:50:FE:8B:56:D2:B5:F1:E7:1D:8D:6F:96:82:26:05:63:5B:44:BB:56:45:B1:D7:6A:EE", "29:61:EB:C6:DE:D4:E3:50:FE:8B:56:D2:B5:F1:E7:1D:8D:6F:96:82:26:05:63:5B:44:BB:56:45:B1:D7:6A:EE",
"4B:80:99:49:D0:30:6F:8F:3C:D0:E7:BB:F6:39:CA:7B:99:A3:17:20:96:F9:4E:1F:13:CC:81:90:54:65:51:3B",
"9D:88:96:66:81:E9:C5:82:5E:96:6C:B7:C4:A0:0C:AA:26:1B:0A:04:F8:7D:9D:F2:4D:C4:3E:02:99:A3:42:DF" "9D:88:96:66:81:E9:C5:82:5E:96:6C:B7:C4:A0:0C:AA:26:1B:0A:04:F8:7D:9D:F2:4D:C4:3E:02:99:A3:42:DF"
] ]
} }