diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 9315dc114..7cb92fe52 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -17,7 +17,7 @@ jobs: - name: Install dependencies run: npm ci - run: echo "${{ secrets.ENV_DEMO }}" > .env - - run: echo "${{ secrets.FUNCTIONS_ENV_DEMO }}" > functions/.env + - run: echo "${{ secrets.FUNCTIONS_ENV_DEMO }}" > functions/.env && echo "SMTP_PASSWORD=" >> functions/.env - name: Set up JDK 21 # Firebase tools requires >=21 uses: actions/setup-java@v4 with: diff --git a/playwright/fixtures.ts b/playwright/fixtures.ts index b58f89c67..b3098e821 100644 --- a/playwright/fixtures.ts +++ b/playwright/fixtures.ts @@ -3,7 +3,20 @@ import fs from 'fs'; import path from 'path'; export * from '@playwright/test'; -export const test = baseTest.extend<{}, { workerStorageState: string }>({ + +/** Generates a worker-specific username based on parallelIndex */ +function getUsernameForWorker(): string { + return `user${test.info().parallelIndex}`; +} + +export const test = baseTest.extend< + { loggedInUsername: string }, + { workerStorageState: string } +>({ + // Provide the test username to all tests in this worker. + loggedInUsername: async ({ }, use) => { + await use(getUsernameForWorker()); + }, // Use the same storage state for all tests in this worker. storageState: ({ workerStorageState }, use) => use(workerStorageState), // Authenticate once per worker with a worker-scoped fixture. @@ -30,7 +43,7 @@ export const test = baseTest.extend<{}, { workerStorageState: string }>({ // Make sure that accounts are unique, so that multiple team members // can run tests at the same time without interference. const account = { - username: `user${id}`, + username: getUsernameForWorker(), password: 'password', }; diff --git a/src/routes/characters/NewCharacterButton.svelte b/src/routes/characters/NewCharacterButton.svelte index dc4cbe6d4..03939b116 100644 --- a/src/routes/characters/NewCharacterButton.svelte +++ b/src/routes/characters/NewCharacterButton.svelte @@ -28,6 +28,7 @@ background={inline} tip={(l) => l.ui.page.characters.button.new} action={addCharacter} + testid="newcharacter" active={!creating} large={!inline} icon="+" diff --git a/tests/end2end/cloud-updates.spec.ts b/tests/end2end/cloud-updates.spec.ts new file mode 100644 index 000000000..d90a2b84c --- /dev/null +++ b/tests/end2end/cloud-updates.spec.ts @@ -0,0 +1,115 @@ + +import { expect, test } from '../../playwright/fixtures'; +import { createTestCharacter } from '../helpers/createCharacter'; +import { createTestProject } from '../helpers/createProject'; +import { updateProjectSource, waitForDocumentUpdate } from '../helpers/firestore'; + +test('editing a project saves it to the cloud', async ({ page }) => { + // Create test project - the page will be redirected to the new project page + const projectId = await createTestProject(page); + + // Make an edit to the project + const newProjectName = "What's in a name"; + const projectNameField = page.locator('#project-name'); + await projectNameField.fill(newProjectName); + expect(await projectNameField.inputValue()).toBe(newProjectName); + + // Wait for the project to be updated in Firestore + const updatedProjectData = await waitForDocumentUpdate( + page, + 'projects', + projectId, + (data) => data?.name === newProjectName, + ); + + // Verify the edit was saved to the cloud + expect(updatedProjectData?.name).toBe(newProjectName); +}); + + +test('editing a custom character saves it to the cloud', async ({ + page, + loggedInUsername, +}) => { + // Create test character - the page will be redirected to the new character page + const characterId = await createTestCharacter(page); + + // Make an edit to the character + const characterNameInput = 'My Cool Character'; + await page.locator('#character-name').fill(characterNameInput); + + // Wait for the character to be updated in Firestore + const expectedFullName = `${loggedInUsername}/${characterNameInput}`; + const updatedCharacterData = await waitForDocumentUpdate( + page, + 'characters', + characterId, + (data) => data?.name === expectedFullName, + ); + + // Verify the edit was saved to the cloud + expect(updatedCharacterData?.name).toBe(expectedFullName); +}); + +test('changing a character name updates its project references', async ({ + page, + loggedInUsername, +}) => { + // Create test character - the page will be redirected to the new character page + const characterId = await createTestCharacter(page); + + // Set a name for the character since the default is empty + const characterNameInput = page.locator('#character-name'); + const initialCharacterName = 'Old'; + await characterNameInput.fill(initialCharacterName); + + // Wait for it to save + const initialCharacterNameFull = `${loggedInUsername}/${initialCharacterName}`; + await waitForDocumentUpdate( + page, + 'characters', + characterId, + (data) => data?.name === initialCharacterNameFull, + ); + + // Create a test project - this will redirect to the project page + const projectId = await createTestProject(page); + + // Wait for the project to be saved to Firestore + await waitForDocumentUpdate( + page, + 'projects', + projectId, + (data) => data?.id === projectId, + ); + + // Update the project source to include a reference to the character + const sourceCodeWithCharacterRef = `Phrase(\`@${initialCharacterNameFull}\`)`; + await updateProjectSource(projectId, sourceCodeWithCharacterRef); + + // Now, rename the character + await page.goto(`/character/${characterId}`); + const newCharacterName = 'New'; + await page.locator('#character-name').fill(newCharacterName); + + // Wait for the character to be updated in Firestore + const expectedFullName = `${loggedInUsername}/${newCharacterName}`; + await waitForDocumentUpdate( + page, + 'characters', + characterId, + (data) => data?.name === expectedFullName, + ); + + // Wait for the project to be updated with the new character reference + const expectedSourceCode = `Phrase(\`@${expectedFullName}\`)`; + const updatedProject = await waitForDocumentUpdate( + page, + 'projects', + projectId, + (data) => data?.sources?.[0]?.code === expectedSourceCode, + ); + + // Verify the character reference was updated in the project + expect(updatedProject?.sources[0].code).toBe(expectedSourceCode); +}); \ No newline at end of file diff --git a/tests/end2end/header.spec.ts b/tests/end2end/header.spec.ts index 2ec7518e2..724eacc4a 100644 --- a/tests/end2end/header.spec.ts +++ b/tests/end2end/header.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import goHome from './goHome'; +import goHome from '../helpers/goHome'; test('has Wordplay header', async ({ page }) => { await goHome(page); diff --git a/tests/end2end/page-headers.spec.ts b/tests/end2end/page-headers.spec.ts index 5b4f9f9e6..9909ebd52 100644 --- a/tests/end2end/page-headers.spec.ts +++ b/tests/end2end/page-headers.spec.ts @@ -1,5 +1,5 @@ import { expect, test, type Page } from '@playwright/test'; -import goHome from './goHome'; +import goHome from '../helpers/goHome'; async function clickLinkAndCheckHeader(page: Page, linkAndHeader: string) { await goHome(page); diff --git a/tests/end2end/project.spec.ts b/tests/end2end/project.spec.ts index 84e6b4850..1becfde2f 100644 --- a/tests/end2end/project.spec.ts +++ b/tests/end2end/project.spec.ts @@ -15,6 +15,9 @@ test('create project and visit its tiles ', async ({ page }) => { // Click to open the collaboration panel await page.getByTestId('collaborate-toggle').click(); + // Click to open the documentation panel + await page.getByTestId('docs-toggle').click(); + // Expect all tiles to be visible. await Promise.all([ expect(page.getByTestId('tile-output')).toBeVisible(), diff --git a/tests/end2end/subtitle-contrast.ts b/tests/end2end/subtitle-contrast.ts index 3b32c242b..4a7349f1b 100644 --- a/tests/end2end/subtitle-contrast.ts +++ b/tests/end2end/subtitle-contrast.ts @@ -1,7 +1,7 @@ import AxeBuilder from '@axe-core/playwright'; import { expect, test } from '@playwright/test'; import type { AxeResults } from 'axe-core'; -import goHome from './goHome'; +import goHome from '../helpers/goHome'; // Print out the accessibility scan results from AxeBuilder. function printAccessibilityScanResults(axeBuilderScanResults: AxeResults) { diff --git a/tests/end2end/title.spec.ts b/tests/end2end/title.spec.ts index 3a69dbe6d..1a5a15a1e 100644 --- a/tests/end2end/title.spec.ts +++ b/tests/end2end/title.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import goHome from './goHome'; +import goHome from '../helpers/goHome'; test('has Wordplay window title', async ({ page }) => { await goHome(page); diff --git a/tests/helpers/createCharacter.ts b/tests/helpers/createCharacter.ts new file mode 100644 index 000000000..ba03ebbe7 --- /dev/null +++ b/tests/helpers/createCharacter.ts @@ -0,0 +1,15 @@ +import type { Page } from '@playwright/test'; + +export async function createTestCharacter(page: Page): Promise { + // Create a new character + await page.goto('/characters'); + await page.getByTestId('newcharacter').click(); + + // Wait for the URL to redirect to the new character page + await page.waitForURL(/\/character\/[^/]+$/); + + // Extract the character ID from the URL + const url = page.url(); + const characterId = url.split('/').pop() as string; + return characterId; +} \ No newline at end of file diff --git a/tests/helpers/createProject.ts b/tests/helpers/createProject.ts new file mode 100644 index 000000000..496b6bf84 --- /dev/null +++ b/tests/helpers/createProject.ts @@ -0,0 +1,15 @@ +import type { Page } from '@playwright/test'; + +export async function createTestProject(page: Page): Promise { + // Create a new project + await page.goto('/projects'); + await page.getByTestId('addproject').click(); + + // Wait for the page to redirect to the new project + await page.waitForURL(/\/project\/[^/]+$/); + + // Extract the project ID from the URL + const url = page.url(); + const projectId = url.split('/').pop() as string; + return projectId; +} \ No newline at end of file diff --git a/tests/helpers/firestore.ts b/tests/helpers/firestore.ts new file mode 100644 index 000000000..21b4b7cac --- /dev/null +++ b/tests/helpers/firestore.ts @@ -0,0 +1,109 @@ +import type { Page } from '@playwright/test'; +import { initializeApp } from 'firebase-admin/app'; +import { getFirestore, type Firestore } from 'firebase-admin/firestore'; + +let firestoreInstance: Firestore | null = null; + +/** + * Returns the Firestore instance used for testing. + */ +export function getTestFirestore(): Firestore { + if (firestoreInstance) { + return firestoreInstance; + } + + const testApp = initializeApp({ + projectId: 'demo-wordplay', + }); + + firestoreInstance = getFirestore(testApp); + + // Connect to the Firestore emulator + firestoreInstance.settings({ + host: 'localhost:8080', + ssl: false, + }); + + return firestoreInstance; +} + +/** + * Fetches a specific document from the test Firestore database. + */ +export async function getTestDocument( + collectionName: string, + documentId: string, +) { + const firestore = getTestFirestore(); + const docRef = firestore.collection(collectionName).doc(documentId); + const docSnap = await docRef.get(); + + if (docSnap.exists) { + return docSnap.data(); + } + return null; +} + +/** + * Updates a project's source code in Firestore directly. + * Useful for setting up test scenarios without going through the UI. + */ +export async function updateProjectSource( + projectId: string, + sourceCode: string, +): Promise { + const firestore = getTestFirestore(); + const projectRef = firestore.collection('projects').doc(projectId); + + const projectSnap = await projectRef.get(); + if (!projectSnap.exists) { + throw new Error(`Project ${projectId} not found in Firestore`); + } + + const projectData = projectSnap.data(); + + if (projectData?.sources && Array.isArray(projectData.sources) && projectData.sources.length > 0) { + projectData.sources[0].code = sourceCode; + + await projectRef.update({ + sources: projectData.sources, + timestamp: Date.now(), + }); + } else { + throw new Error(`Could not update project source: project ${projectId} has missing or invalid sources field`); + } +} + +/** + * Waits for a document in Firestore to meet a specific condition. + * Useful when waiting for updates to be saved to the cloud. + * + * @param page - The Playwright page (needed for waitForTimeout) + * @param collectionName - The collection name + * @param documentId - The document ID + * @param predicate - A function that takes the document data and returns true when the condition is met + * @param timeout - Maximum time to wait in milliseconds (default: 5000) + * @param interval - Polling interval in milliseconds (default: 100) + * @returns The document data when the condition is met, or after the check has timed out + */ +export async function waitForDocumentUpdate( + page: Page, + collectionName: string, + documentId: string, + predicate: (data: FirebaseFirestore.DocumentData | null | undefined) => boolean, + timeout = 5000, + interval = 100, +) { + let documentData; + const startTime = Date.now(); + + while (Date.now() - startTime < timeout) { + documentData = await getTestDocument(collectionName, documentId); + if (documentData && predicate(documentData)) { + break; + } + await page.waitForTimeout(interval); + } + + return documentData; +} diff --git a/tests/end2end/goHome.ts b/tests/helpers/goHome.ts similarity index 100% rename from tests/end2end/goHome.ts rename to tests/helpers/goHome.ts