Skip to content
Merged
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
57 changes: 57 additions & 0 deletions frontend/src/__tests__/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,63 @@ describe('App Module', () => {
});

describe('setupEventListeners', () => {
describe('feedback context', () => {
let originalURL: string;
let link: HTMLAnchorElement;

beforeEach(() => {
originalURL = window.location.href;
jest.useFakeTimers().setSystemTime(new Date('2026-10-13T23:59:00Z'));
window.history.replaceState(null, '', '/purchases');
document.body.innerHTML = '<a id="feedback-link">Feedback</a>';
link = document.getElementById('feedback-link') as HTMLAnchorElement;
link.addEventListener('click', event => event.preventDefault());
setupEventListeners();
});

afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
window.history.replaceState(null, '', originalURL);
});

function expectContext(date: string): void {
const params = new URLSearchParams(link.href.split('?')[1]);
expect(link.href).toMatch(/^mailto:/);
expect(params.get('subject')).toBe('CUDly Feedback');
expect(params.get('body')).toContain('## Steps to Reproduce (for bugs)');
expect(params.get('body')).toContain(`- Browser: ${navigator.userAgent}`);
expect(params.get('body')).toContain(`- URL: ${window.location.href}`);
expect(params.get('body')).toContain(`- Date: ${date}`);
}

test('provides initial feedback context', () => {
expectContext('2026-10-13T23:59:00.000Z');
});

test.each(['/plans', '/home', '/opportunities?filter=a%26b%2Bc#details'])('uses the current %s URL and date when activated', path => {
link.focus();
window.history.pushState(null, '', path);
jest.setSystemTime(new Date('2026-10-14T00:01:00Z'));
link.click();
expectContext('2026-10-14T00:01:00.000Z');
});

test.each(['focus', 'contextmenu'])('refreshes context on %s for link inspection', event => {
window.history.pushState(null, '', '/plans');
jest.setSystemTime(new Date('2026-10-14T00:02:00Z'));
link.dispatchEvent(new Event(event, { cancelable: true }));
expectContext('2026-10-14T00:02:00.000Z');
});

test('refreshes once after repeated setup', () => {
setupEventListeners();
const writes = jest.spyOn(link, 'href', 'set');
link.click();
expect(writes).toHaveBeenCalledTimes(1);
});
});

test('sets up tab button click handlers', () => {
document.body.innerHTML = `
<button class="tab-btn" data-tab="dashboard">Dashboard</button>
Expand Down
13 changes: 10 additions & 3 deletions frontend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -919,7 +919,8 @@ function setupFeedbackLink(): void {
const feedbackEmail = 'contact@leanercloud.com';
const subject = 'CUDly Feedback';

const body = `Hi CUDly Team,
const refreshFeedbackLink = (): void => {
const body = `Hi CUDly Team,

I'd like to share some feedback about CUDly:

Expand Down Expand Up @@ -967,6 +968,12 @@ Please attach any relevant screenshots to this email.
Thank you for helping us improve CUDly!
`;

const mailtoUrl = `mailto:${feedbackEmail}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
feedbackLink.href = mailtoUrl;
const mailtoUrl = `mailto:${feedbackEmail}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
feedbackLink.href = mailtoUrl;
};

refreshFeedbackLink();
feedbackLink.onclick = refreshFeedbackLink;
feedbackLink.onfocus = refreshFeedbackLink;
feedbackLink.oncontextmenu = refreshFeedbackLink;
}
75 changes: 75 additions & 0 deletions frontend/tests-e2e/feedback-link.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { test, expect, type Page } from '@playwright/test';
import { mockApi, seedAuth } from './fixtures/recs';

async function expectFeedbackContext(page: Page, date: string): Promise<void> {
const href = await page.locator('#feedback-link').getAttribute('href');
const params = new URLSearchParams(href?.split('?')[1]);
expect(href).toMatch(/^mailto:/);
expect(params.get('subject')).toBe('CUDly Feedback');
expect(params.get('body')).toContain(`- URL: ${page.url()}`);
expect(params.get('body')).toContain(`- Date: ${date}`);
expect(params.get('body')).toContain('## Steps to Reproduce (for bugs)');
}

test('feedback uses the current SPA route and time on mouse, keyboard, focus and context menu', async ({ page }) => {
const pageErrors: string[] = [];
const consoleErrors: string[] = [];
const failedRequests: string[] = [];
page.on('pageerror', error => pageErrors.push(String(error)));
page.on('console', message => {
if (message.type() === 'error') consoleErrors.push(message.text());
});
page.on('response', response => {
if (response.status() >= 400) failedRequests.push(`${response.status()} ${new URL(response.url()).pathname}`);
});
page.on('requestfailed', request => failedRequests.push(new URL(request.url()).pathname));
await seedAuth(page);
await mockApi(page);
await page.route('**/api/history?*', route => route.fulfill({ json: [] }));
await page.route('**/api/history/analytics?*', route => route.fulfill({ json: {
start: '2026-10-01T00:00:00Z', end: '2026-10-14T00:00:00Z', interval: 'day',
summary: { total_period_savings: 0, total_upfront_spent: 0, purchase_count: 0, average_savings_per_period: 0, peak_savings: 0 },
data_points: [],
} }));
await page.route('**/api/purchases/planned', route => route.fulfill({ json: { purchases: [] } }));
await page.route('**/api/dashboard/upcoming', route => route.fulfill({ json: [] }));
await page.route('**/api/dashboard/summary?*', route => route.fulfill({ json: {
potential_monthly_savings: 0, total_recommendations: 0, active_commitments: 0,
committed_monthly: 0, current_coverage: 0, target_coverage: 0, ytd_savings: 0, by_service: {},
} }));
await page.clock.setFixedTime(new Date('2026-10-13T23:59:00Z'));
await page.goto('/purchases');
await expect(page.locator('#purchases-tab.active')).toBeVisible();
const originalDocument = await page.evaluate(() => performance.timeOrigin);
const feedback = page.locator('#feedback-link');
await expectFeedbackContext(page, '2026-10-13T23:59:00.000Z');
await feedback.evaluate(link => {
link.addEventListener('click', event => event.preventDefault());
link.addEventListener('contextmenu', event => event.preventDefault());
});

for (const tab of ['plans', 'home', 'opportunities']) {
await page.locator(`#sidebar [data-tab="${tab}"]`).click();
await expect(page.locator(`#${tab}-tab.active`)).toBeVisible();
await feedback.focus();
await page.clock.setFixedTime(new Date('2026-10-14T00:01:00Z'));
await feedback.press('Enter');
await expectFeedbackContext(page, '2026-10-14T00:01:00.000Z');
await page.clock.setFixedTime(new Date('2026-10-14T00:02:00Z'));
await feedback.click();
await expectFeedbackContext(page, '2026-10-14T00:02:00.000Z');
expect(await page.evaluate(() => performance.timeOrigin)).toBe(originalDocument);
}

await page.evaluate(() => window.history.pushState(null, '', '/opportunities?filter=a%26b%2Bc#details'));
await page.clock.setFixedTime(new Date('2026-10-14T00:03:00Z'));
await feedback.click({ button: 'right' });
await expectFeedbackContext(page, '2026-10-14T00:03:00.000Z');
await page.locator('#sidebar [data-tab="home"]').focus();
await page.clock.setFixedTime(new Date('2026-10-14T00:04:00Z'));
await feedback.focus();
await expectFeedbackContext(page, '2026-10-14T00:04:00.000Z');
expect(pageErrors).toEqual([]);
expect(failedRequests).toEqual([]);
expect(consoleErrors).toEqual([]);
});
Loading