Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/playwright.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 "SMTP_PASSWORD=dummy-password-for-ci" > functions/.env
- name: Set up JDK 21 # Firebase tools requires >=21
uses: actions/setup-java@v4
with:
Expand All @@ -30,7 +30,7 @@ jobs:
- name: Build project
run: npm run build
- name: Run end-to-end tests with emulator
run: firebase emulators:exec "npx playwright test --reporter=list" --project=demo-wordplay --log-verbosity QUIET
run: firebase emulators:exec "npx playwright test --reporter=list" --project=demo-wordplay
- uses: actions/upload-artifact@v4
if: always()
with:
Expand Down
17 changes: 15 additions & 2 deletions playwright/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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',
};

Expand Down
1 change: 1 addition & 0 deletions src/routes/characters/NewCharacterButton.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
background={inline}
tip={(l) => l.ui.page.characters.button.new}
action={addCharacter}
testid="newcharacter"
active={!creating}
large={!inline}
icon="+"
Expand Down
115 changes: 115 additions & 0 deletions tests/end2end/cloud-updates.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
2 changes: 1 addition & 1 deletion tests/end2end/header.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down
2 changes: 1 addition & 1 deletion tests/end2end/page-headers.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down
2 changes: 1 addition & 1 deletion tests/end2end/subtitle-contrast.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion tests/end2end/title.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down
15 changes: 15 additions & 0 deletions tests/helpers/createCharacter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { Page } from '@playwright/test';

export async function createTestCharacter(page: Page): Promise<string> {
// 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;
}
15 changes: 15 additions & 0 deletions tests/helpers/createProject.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { Page } from '@playwright/test';

export async function createTestProject(page: Page): Promise<string> {
// 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;
}
102 changes: 102 additions & 0 deletions tests/helpers/firestore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
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',
});

firestoreInstance = getFirestore(testApp);
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<void> {
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;
}
File renamed without changes.
Loading