mirror of
https://github.com/immich-app/static-pages.git
synced 2026-09-30 13:23:05 +08:00
506 lines
18 KiB
TypeScript
506 lines
18 KiB
TypeScript
import { test, expect, type Page } from '@playwright/test';
|
|
import { apiPost, apiPut, apiGet, apiRawGet, API, ensureAuth, parseCookie } from './helpers';
|
|
|
|
let cookie: string;
|
|
|
|
test.beforeAll(async () => {
|
|
cookie = await ensureAuth();
|
|
});
|
|
|
|
test.beforeEach(async ({ context }) => {
|
|
const BASE = process.env.BASE_URL || 'http://localhost:5173';
|
|
const { name, value } = parseCookie(cookie);
|
|
await context.addCookies([{ name, value, url: BASE }]);
|
|
});
|
|
|
|
async function waitForTransition(page: Page) {
|
|
await page.waitForTimeout(400);
|
|
}
|
|
|
|
async function dismissSectionHeader(page: Page) {
|
|
await page.getByRole('button', { name: 'Continue' }).click();
|
|
}
|
|
|
|
interface SurveySetup {
|
|
surveyId: string;
|
|
slug: string;
|
|
sectionId: string;
|
|
questionIds: string[];
|
|
}
|
|
|
|
async function createSimpleSurvey(opts?: {
|
|
title?: string;
|
|
slug?: string;
|
|
questionCount?: number;
|
|
questionType?: string;
|
|
required?: boolean;
|
|
publish?: boolean;
|
|
options?: Array<{ label: string; value: string }>;
|
|
closesAt?: string;
|
|
maxResponses?: number;
|
|
}): Promise<SurveySetup> {
|
|
const title = opts?.title ?? 'E2E Phase2 Test';
|
|
const slug = opts?.slug ?? `e2e-p2-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
const questionCount = opts?.questionCount ?? 1;
|
|
const questionType = opts?.questionType ?? 'radio';
|
|
const required = opts?.required ?? true;
|
|
const publish = opts?.publish ?? false;
|
|
|
|
const survey = await apiPost('/api/surveys', { title });
|
|
const section = await apiPost(`/api/surveys/${survey.id}/sections`, { title: 'Section 1' });
|
|
|
|
const questionIds: string[] = [];
|
|
for (let i = 0; i < questionCount; i++) {
|
|
const body: Record<string, unknown> = {
|
|
text: `Question ${i + 1}`,
|
|
type: questionType,
|
|
required,
|
|
};
|
|
if (['radio', 'checkbox', 'dropdown'].includes(questionType)) {
|
|
body.options = opts?.options ?? [
|
|
{ label: 'Alpha', value: 'Alpha' },
|
|
{ label: 'Beta', value: 'Beta' },
|
|
{ label: 'Gamma', value: 'Gamma' },
|
|
];
|
|
}
|
|
const q = await apiPost(`/api/surveys/${survey.id}/sections/${section.id}/questions`, body);
|
|
questionIds.push(q.id);
|
|
}
|
|
|
|
await apiPut(`/api/surveys/${survey.id}`, {
|
|
slug,
|
|
...(opts?.closesAt && { closes_at: opts.closesAt }),
|
|
...((opts?.maxResponses !== undefined) && { max_responses: opts.maxResponses }),
|
|
});
|
|
|
|
if (publish) {
|
|
await apiPut(`/api/surveys/${survey.id}/publish`);
|
|
}
|
|
|
|
return { surveyId: survey.id, slug, sectionId: section.id, questionIds };
|
|
}
|
|
|
|
test.describe('Create page with survey templates', () => {
|
|
test('template cards are visible and clicking one pre-populates the builder', async ({ page }) => {
|
|
await page.goto('/create');
|
|
|
|
await expect(page.getByText('Customer Satisfaction')).toBeVisible({ timeout: 5000 });
|
|
await expect(page.getByText('Event Feedback')).toBeVisible();
|
|
await expect(page.getByText('Employee Engagement')).toBeVisible();
|
|
await expect(page.getByText('Blank Survey')).toBeVisible();
|
|
});
|
|
|
|
test('clicking a template shows builder with pre-populated title and saves correctly', async ({ page }) => {
|
|
await page.goto('/create');
|
|
await expect(page.getByText('Customer Satisfaction')).toBeVisible({ timeout: 5000 });
|
|
|
|
await page.getByText('Customer Satisfaction').first().click();
|
|
await waitForTransition(page);
|
|
|
|
await expect(page.getByText('Create Survey')).toBeVisible({ timeout: 5000 });
|
|
const titleInput = page.locator('input[placeholder="Survey title..."]');
|
|
await expect(titleInput).toHaveValue('Customer Satisfaction');
|
|
|
|
await page.getByRole('button', { name: 'Save' }).click();
|
|
|
|
await page.waitForURL(/\/edit\/[a-f0-9-]+/, { timeout: 10_000 });
|
|
await expect(page.getByText('Edit Survey')).toBeVisible({ timeout: 5000 });
|
|
|
|
const url = page.url();
|
|
const surveyId = url.match(/\/edit\/([a-f0-9-]+)/)?.[1];
|
|
expect(surveyId).toBeTruthy();
|
|
|
|
const data = await apiGet(`/api/surveys/${surveyId}`);
|
|
expect(data.sections.length).toBeGreaterThanOrEqual(2);
|
|
expect(data.questions.length).toBeGreaterThanOrEqual(3);
|
|
});
|
|
});
|
|
|
|
test.describe('Create page saves sections (blank survey)', () => {
|
|
test('creates a blank survey and adds sections on edit page', async ({ page }) => {
|
|
await page.goto('/create');
|
|
await expect(page.getByText('Blank Survey')).toBeVisible({ timeout: 5000 });
|
|
|
|
await page.getByText('Blank Survey').click();
|
|
await waitForTransition(page);
|
|
|
|
await page.locator('input[placeholder="Survey title..."]').fill('Blank Builder E2E');
|
|
|
|
await page.getByRole('button', { name: 'Save' }).click();
|
|
|
|
await page.waitForURL(/\/edit\/[a-f0-9-]+/, { timeout: 10_000 });
|
|
await expect(page.getByText('Edit Survey')).toBeVisible({ timeout: 5000 });
|
|
|
|
await page.getByText('Add section').click();
|
|
await waitForTransition(page);
|
|
|
|
await page.getByPlaceholder('e.g., About You').fill('Demo Section');
|
|
|
|
await page.getByRole('button', { name: 'Single Choice' }).last().click();
|
|
await waitForTransition(page);
|
|
|
|
await page.getByPlaceholder('What would you like to ask?').fill('Favorite fruit?');
|
|
|
|
await page.getByRole('button', { name: 'Save' }).click();
|
|
await expect(page.getByText('Changes saved')).toBeVisible({ timeout: 5000 });
|
|
|
|
const surveyId = page.url().match(/\/edit\/([a-f0-9-]+)/)?.[1];
|
|
expect(surveyId).toBeTruthy();
|
|
|
|
const data = await apiGet(`/api/surveys/${surveyId}`);
|
|
expect(data.survey.title).toBe('Blank Builder E2E');
|
|
expect(data.sections.length).toBe(1);
|
|
expect(data.questions.length).toBe(1);
|
|
expect(data.questions[0].text).toBe('Favorite fruit?');
|
|
});
|
|
});
|
|
|
|
test.describe('Preview mode', () => {
|
|
let setup: SurveySetup;
|
|
|
|
test.beforeAll(async () => {
|
|
setup = await createSimpleSurvey({ title: 'Preview Test Survey' });
|
|
});
|
|
|
|
test('preview modal opens and shows survey content', async ({ page }) => {
|
|
await page.goto(`/edit/${setup.surveyId}`);
|
|
await expect(page.getByText('Edit Survey')).toBeVisible({ timeout: 5000 });
|
|
|
|
await page.getByRole('button', { name: 'Preview' }).click();
|
|
await waitForTransition(page);
|
|
|
|
const previewModal = page.locator('.fixed.inset-0.z-50');
|
|
await expect(previewModal).toBeVisible({ timeout: 3000 });
|
|
|
|
const getStartedBtn = previewModal.getByRole('button', { name: 'Get Started' });
|
|
await expect(getStartedBtn).toBeVisible({ timeout: 3000 });
|
|
|
|
await getStartedBtn.click();
|
|
await waitForTransition(page);
|
|
|
|
const continueBtn = previewModal.getByRole('button', { name: 'Continue' });
|
|
await continueBtn.click();
|
|
await waitForTransition(page);
|
|
|
|
await expect(previewModal.getByText('Question 1')).toBeVisible({ timeout: 3000 });
|
|
|
|
await page.keyboard.press('Escape');
|
|
await waitForTransition(page);
|
|
|
|
await expect(page.getByText('Edit Survey')).toBeVisible({ timeout: 3000 });
|
|
});
|
|
});
|
|
|
|
test.describe('Inline validation errors', () => {
|
|
let setup: SurveySetup;
|
|
|
|
test.beforeAll(async () => {
|
|
setup = await createSimpleSurvey({
|
|
title: 'Validation Test',
|
|
required: true,
|
|
publish: true,
|
|
});
|
|
});
|
|
|
|
test('shows "This question is required" when submitting without answer', async ({ page }) => {
|
|
await page.goto(`/s/${setup.slug}`);
|
|
await page.getByRole('button', { name: 'Get Started' }).click();
|
|
await waitForTransition(page);
|
|
|
|
await dismissSectionHeader(page);
|
|
await waitForTransition(page);
|
|
|
|
await expect(page.getByRole('heading', { name: 'Question 1' })).toBeVisible({ timeout: 3000 });
|
|
|
|
await page
|
|
.getByRole('button', { name: /Next|Submit/ })
|
|
.first()
|
|
.click();
|
|
await waitForTransition(page);
|
|
|
|
await expect(page.getByRole('alert').getByText('This question is required')).toBeVisible({ timeout: 3000 });
|
|
});
|
|
});
|
|
|
|
test.describe('Keyboard navigation', () => {
|
|
let setup: SurveySetup;
|
|
|
|
test.beforeAll(async () => {
|
|
setup = await createSimpleSurvey({
|
|
title: 'Keyboard Nav Test',
|
|
questionType: 'radio',
|
|
questionCount: 2,
|
|
publish: true,
|
|
});
|
|
});
|
|
|
|
test('ArrowDown selects radio option via keyboard', async ({ page }) => {
|
|
await page.goto(`/s/${setup.slug}`);
|
|
await page.getByRole('button', { name: 'Get Started' }).click();
|
|
await waitForTransition(page);
|
|
|
|
await dismissSectionHeader(page);
|
|
await waitForTransition(page);
|
|
|
|
await expect(page.getByRole('heading', { name: 'Question 1' })).toBeVisible({ timeout: 3000 });
|
|
|
|
await page.keyboard.press('Tab');
|
|
await page.keyboard.press('ArrowDown');
|
|
await waitForTransition(page);
|
|
|
|
// Verify Alpha is selected — radio auto-advances to Question 2
|
|
await expect(page.getByRole('heading', { name: 'Question 2' })).toBeVisible({ timeout: 5000 });
|
|
});
|
|
});
|
|
|
|
test.describe('Survey scheduling and limits', () => {
|
|
test('closed survey blocks responses via API', async () => {
|
|
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
|
const setup = await createSimpleSurvey({
|
|
title: 'Closed Survey Test',
|
|
closesAt: oneDayAgo,
|
|
publish: true,
|
|
});
|
|
|
|
const res = await apiRawGet(`/api/s/${setup.slug}/resume`);
|
|
expect(res.status).toBe(403);
|
|
const body = await res.json();
|
|
expect(body.error).toContain('closed');
|
|
});
|
|
|
|
test('max_responses blocks after limit is reached', async () => {
|
|
const setup = await createSimpleSurvey({
|
|
title: 'Max Responses Test',
|
|
maxResponses: 1,
|
|
publish: true,
|
|
});
|
|
|
|
const resume1Res = await apiRawGet(`/api/s/${setup.slug}/resume`);
|
|
expect(resume1Res.status).toBe(200);
|
|
const cookies1 = resume1Res.headers.get('set-cookie') ?? '';
|
|
const ridMatch1 = cookies1.match(/rid_[^=]+=([^;]+)/);
|
|
const rid1 = ridMatch1?.[1];
|
|
expect(rid1).toBeTruthy();
|
|
|
|
await fetch(`${API}/api/s/${setup.slug}/answers/batch`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Cookie: `rid_${setup.slug}=${rid1}` },
|
|
body: JSON.stringify({ answers: [{ questionId: setup.questionIds[0], value: 'Alpha' }] }),
|
|
});
|
|
|
|
await fetch(`${API}/api/s/${setup.slug}/complete`, {
|
|
method: 'POST',
|
|
headers: { Cookie: `rid_${setup.slug}=${rid1}` },
|
|
});
|
|
|
|
const resume2Res = await apiRawGet(`/api/s/${setup.slug}/resume`);
|
|
expect(resume2Res.status).toBe(403);
|
|
const body2 = await resume2Res.json();
|
|
expect(body2.error).toContain('maximum');
|
|
});
|
|
});
|
|
|
|
test.describe('Question reordering via builder', () => {
|
|
let setup: SurveySetup;
|
|
|
|
test.beforeAll(async () => {
|
|
setup = await createSimpleSurvey({
|
|
title: 'Reorder Test',
|
|
questionCount: 3,
|
|
});
|
|
});
|
|
|
|
test('move up/down buttons reorder questions and save persists order', async ({ page }) => {
|
|
await page.goto(`/edit/${setup.surveyId}`);
|
|
await expect(page.getByText('Edit Survey')).toBeVisible({ timeout: 5000 });
|
|
|
|
await expect(page.getByText('3 questions').first()).toBeVisible({ timeout: 3000 });
|
|
|
|
await page.getByText('Question 3').click();
|
|
await waitForTransition(page);
|
|
|
|
const expandedQuestion = page.locator('[data-question-index="0-2"]');
|
|
const moveUpButton = expandedQuestion.locator('button[title="Move up"]');
|
|
await moveUpButton.click();
|
|
await waitForTransition(page);
|
|
|
|
await page.getByRole('button', { name: 'Save' }).click();
|
|
await waitForTransition(page);
|
|
await expect(page.getByText('Changes saved')).toBeVisible({ timeout: 5000 });
|
|
|
|
const data = await apiGet(`/api/surveys/${setup.surveyId}`);
|
|
const questions = data.questions.sort(
|
|
(a: Record<string, number>, b: Record<string, number>) => a.sort_order - b.sort_order,
|
|
);
|
|
// Question 3 should now have sort_order 1 (moved up from 2)
|
|
const q3 = questions.find((q: Record<string, string>) => q.text === 'Question 3');
|
|
expect(q3.sort_order).toBe(1);
|
|
});
|
|
});
|
|
|
|
test.describe('Question templates in builder', () => {
|
|
let surveyId: string;
|
|
|
|
test.beforeAll(async () => {
|
|
const survey = await apiPost('/api/surveys', { title: 'Template Q Test' });
|
|
const section = await apiPost(`/api/surveys/${survey.id}/sections`, { title: 'Template Section' });
|
|
await apiPost(`/api/surveys/${survey.id}/sections/${section.id}/questions`, {
|
|
text: 'Placeholder Q',
|
|
type: 'text',
|
|
});
|
|
surveyId = survey.id;
|
|
});
|
|
|
|
test('template dropdown shows categories and adds a template question', async ({ page }) => {
|
|
await page.goto(`/edit/${surveyId}`);
|
|
await expect(page.getByText('Edit Survey')).toBeVisible({ timeout: 5000 });
|
|
|
|
await page.getByRole('button', { name: 'Template' }).click();
|
|
await waitForTransition(page);
|
|
|
|
// Category headers need exact match so they don't match other text on the page.
|
|
const dropdown = page.locator('.absolute.top-full');
|
|
await expect(dropdown).toBeVisible({ timeout: 3000 });
|
|
await expect(dropdown.getByText('Feedback', { exact: true })).toBeVisible();
|
|
await expect(dropdown.getByText('Demographics', { exact: true })).toBeVisible();
|
|
|
|
await dropdown.getByRole('button', { name: 'Net Promoter Score' }).click();
|
|
await waitForTransition(page);
|
|
|
|
await expect(page.getByText('How likely are you to recommend us to a friend or colleague?')).toBeVisible({
|
|
timeout: 3000,
|
|
});
|
|
});
|
|
});
|
|
|
|
test.describe('Bulk option paste', () => {
|
|
let surveyId: string;
|
|
|
|
test.beforeAll(async () => {
|
|
const survey = await apiPost('/api/surveys', { title: 'Bulk Paste Test' });
|
|
const section = await apiPost(`/api/surveys/${survey.id}/sections`, { title: 'S1' });
|
|
await apiPost(`/api/surveys/${survey.id}/sections/${section.id}/questions`, {
|
|
text: 'Paste Q',
|
|
type: 'radio',
|
|
options: [
|
|
{ label: 'A', value: 'A' },
|
|
{ label: 'B', value: 'B' },
|
|
],
|
|
});
|
|
surveyId = survey.id;
|
|
});
|
|
|
|
test('paste options modal replaces options', async ({ page }) => {
|
|
await page.goto(`/edit/${surveyId}`);
|
|
await expect(page.getByText('Edit Survey')).toBeVisible({ timeout: 5000 });
|
|
|
|
await page.getByText('Paste Q').click();
|
|
await waitForTransition(page);
|
|
|
|
await page.getByRole('button', { name: 'Paste options' }).click();
|
|
await waitForTransition(page);
|
|
|
|
await expect(page.getByRole('heading', { name: 'Paste Options' })).toBeVisible({ timeout: 3000 });
|
|
|
|
const modal = page.locator('.fixed.inset-0.z-50');
|
|
const textarea = modal.locator('textarea');
|
|
await textarea.fill('Red\nGreen\nBlue\nYellow');
|
|
|
|
await expect(modal.getByText('4 options')).toBeVisible({ timeout: 3000 });
|
|
|
|
await modal.getByRole('button', { name: 'Apply' }).click();
|
|
await waitForTransition(page);
|
|
|
|
// OptionListEditor renders one input per option.
|
|
const optionInputs = page.locator(String.raw`.space-y-1\.5 input[placeholder="Option label..."]`);
|
|
await expect(optionInputs).toHaveCount(4, { timeout: 3000 });
|
|
await expect(optionInputs.nth(0)).toHaveValue('Red');
|
|
await expect(optionInputs.nth(1)).toHaveValue('Green');
|
|
await expect(optionInputs.nth(2)).toHaveValue('Blue');
|
|
await expect(optionInputs.nth(3)).toHaveValue('Yellow');
|
|
});
|
|
});
|
|
|
|
test.describe('Undo/redo', () => {
|
|
let surveyId: string;
|
|
|
|
test.beforeAll(async () => {
|
|
const survey = await apiPost('/api/surveys', { title: 'Undo Redo Test' });
|
|
surveyId = survey.id;
|
|
});
|
|
|
|
test('Ctrl+Z undoes title change', async ({ page }) => {
|
|
await page.goto(`/edit/${surveyId}`);
|
|
await expect(page.getByText('Edit Survey')).toBeVisible({ timeout: 5000 });
|
|
|
|
const titleInput = page.locator('input[placeholder="Survey title..."]');
|
|
await expect(titleInput).toHaveValue('Undo Redo Test');
|
|
|
|
// Wait for initial snapshot to be taken (debounce timer is 500ms)
|
|
await page.waitForTimeout(800);
|
|
|
|
await titleInput.fill('Changed Title');
|
|
await expect(titleInput).toHaveValue('Changed Title');
|
|
|
|
// Wait for the changed snapshot to be recorded
|
|
await page.waitForTimeout(800);
|
|
|
|
// Press Ctrl+Z (Cmd+Z on Mac) twice — the undo stack contains
|
|
// [initial state, changed state]. First undo pops the changed state
|
|
// (no-op since it matches current), second undo pops the initial state.
|
|
const modifier = process.platform === 'darwin' ? 'Meta' : 'Control';
|
|
await page.keyboard.press(`${modifier}+z`);
|
|
await page.waitForTimeout(200);
|
|
await page.keyboard.press(`${modifier}+z`);
|
|
await waitForTransition(page);
|
|
|
|
await expect(titleInput).toHaveValue('Undo Redo Test', { timeout: 3000 });
|
|
});
|
|
});
|
|
|
|
test.describe('Skip logic builder UI', () => {
|
|
test('skip logic dropdown shows preceding questions from the same section', async ({ page }) => {
|
|
const setup = await createSimpleSurvey({
|
|
title: 'Skip Logic Builder Test',
|
|
questionCount: 3,
|
|
});
|
|
|
|
await page.goto(`/edit/${setup.surveyId}`);
|
|
await expect(page.getByText('Edit Survey')).toBeVisible({ timeout: 5000 });
|
|
|
|
await page.getByRole('button', { name: /Question 3/ }).click();
|
|
await waitForTransition(page);
|
|
|
|
await page.getByText('Skip Logic').click();
|
|
await waitForTransition(page);
|
|
|
|
await expect(page.getByText('No preceding questions')).not.toBeVisible();
|
|
|
|
await expect(page.getByText('Source question')).toBeVisible({ timeout: 3000 });
|
|
const sourceLabel = page.getByText('Source question');
|
|
const select = sourceLabel.locator('..').locator('select');
|
|
await expect(select).toBeVisible();
|
|
|
|
await expect(select.locator('option', { hasText: 'Q1:' })).toBeAttached();
|
|
await expect(select.locator('option', { hasText: 'Q2:' })).toBeAttached();
|
|
});
|
|
|
|
test('first question shows no preceding questions message', async ({ page }) => {
|
|
const setup = await createSimpleSurvey({
|
|
title: 'Skip Logic First Q Test',
|
|
questionCount: 2,
|
|
});
|
|
|
|
await page.goto(`/edit/${setup.surveyId}`);
|
|
await expect(page.getByText('Edit Survey')).toBeVisible({ timeout: 5000 });
|
|
|
|
await page.getByRole('button', { name: /Question 1/ }).click();
|
|
await waitForTransition(page);
|
|
|
|
await page.getByText('Skip Logic').click();
|
|
await waitForTransition(page);
|
|
|
|
await expect(page.getByText('No preceding questions')).toBeVisible({ timeout: 3000 });
|
|
});
|
|
});
|