chore: import upstream snapshot with attribution
i18n - Build Validation / Validate i18n Builds (24) (push) Has been cancelled
CI - Node.js / Lint (24) (push) Has been cancelled
CI - Node.js / Build (24) (push) Has been cancelled
CI - Node.js / Test (24) (push) Has been cancelled
CI - Node.js / Test - Upcoming Changes (24) (push) Has been cancelled
CI - Node.js / Test - i18n (italian, 24) (push) Has been cancelled
CI - Node.js / Test - i18n (portuguese, 24) (push) Has been cancelled
CD - Docker - GHCR Images / Build and Push Images (push) Has been cancelled
i18n - Build Validation / Validate i18n Builds (24) (push) Has been cancelled
CI - Node.js / Lint (24) (push) Has been cancelled
CI - Node.js / Build (24) (push) Has been cancelled
CI - Node.js / Test (24) (push) Has been cancelled
CI - Node.js / Test - Upcoming Changes (24) (push) Has been cancelled
CI - Node.js / Test - i18n (italian, 24) (push) Has been cancelled
CI - Node.js / Test - i18n (portuguese, 24) (push) Has been cancelled
CD - Docker - GHCR Images / Build and Push Images (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1 @@
|
||||
playwright/
|
||||
@@ -0,0 +1,4 @@
|
||||
/* eslint-disable filenames-simple/naming-convention */
|
||||
import { createLintStagedConfig } from '@freecodecamp/eslint-config/lintstaged';
|
||||
|
||||
export default createLintStagedConfig(import.meta.dirname);
|
||||
@@ -0,0 +1,57 @@
|
||||
import { execSync } from 'child_process';
|
||||
import { test, expect } from '@playwright/test';
|
||||
import translations from '../client/i18n/locales/english/translations.json';
|
||||
import { alertToBeVisible } from './utils/alerts';
|
||||
|
||||
test.describe('When the user has not accepted the Academic Honesty Policy', () => {
|
||||
test.use({ storageState: 'playwright/.auth/development-user.json' });
|
||||
|
||||
test.beforeEach(() => {
|
||||
execSync('node ../tools/scripts/seed/seed-demo-user');
|
||||
});
|
||||
|
||||
test.afterAll(() => {
|
||||
execSync('node ../tools/scripts/seed/seed-demo-user --certified-user');
|
||||
});
|
||||
|
||||
test('they should be able to accept it', async ({ page }) => {
|
||||
await page.goto('/settings#honesty');
|
||||
|
||||
const agreeButton = page.getByRole('button', {
|
||||
name: translations.buttons['agree-honesty']
|
||||
});
|
||||
await agreeButton.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: translations.buttons['accepted-honesty']
|
||||
})
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('Should show an error message', async ({ page }) => {
|
||||
await page.goto('/settings#cert-responsive-web-design');
|
||||
|
||||
const claimCertButton = page.getByRole('button', {
|
||||
name: 'Claim Certification Legacy Responsive Web Design V8'
|
||||
});
|
||||
await claimCertButton.click();
|
||||
|
||||
await alertToBeVisible(page, translations.flash['honest-first']);
|
||||
|
||||
const agreeButton = page.getByRole('button', {
|
||||
name: translations.buttons['agree-honesty']
|
||||
});
|
||||
await agreeButton.click();
|
||||
await alertToBeVisible(page, translations.buttons['accepted-honesty']);
|
||||
|
||||
await page.reload();
|
||||
|
||||
await claimCertButton.click();
|
||||
|
||||
await alertToBeVisible(
|
||||
page,
|
||||
'It looks like you have not completed the necessary steps. Please complete the required projects to claim the Responsive Web Design Certification.'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import translations from '../client/i18n/locales/english/translations.json';
|
||||
|
||||
test.describe('Desktop view', () => {
|
||||
test.skip(({ isMobile }) => isMobile, 'Only test on desktop');
|
||||
|
||||
test.describe('Pages with previews', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(
|
||||
'/learn/responsive-web-design-v9/lab-event-flyer-page/build-an-event-flyer-page'
|
||||
);
|
||||
});
|
||||
|
||||
test('Clicking instructions button hides the instructions panel', async ({
|
||||
page
|
||||
}) => {
|
||||
const instructionsButton = page.getByTestId('instructions-button');
|
||||
|
||||
await instructionsButton.click();
|
||||
|
||||
const instructionsPanelTitle = page.getByRole('heading', {
|
||||
name: 'Build an Event Flyer Page'
|
||||
});
|
||||
await expect(instructionsPanelTitle).toBeHidden();
|
||||
});
|
||||
|
||||
test('Clicking Console button shows console panel', async ({ page }) => {
|
||||
const actionRow = page.getByTestId('action-row');
|
||||
const consoleText = translations.learn['editor-tabs'].console;
|
||||
const consoleBtn = actionRow.getByRole('button', { name: consoleText });
|
||||
|
||||
await consoleBtn.click();
|
||||
const consolePanel = page.getByLabel(consoleText);
|
||||
await expect(consolePanel).toBeVisible();
|
||||
});
|
||||
|
||||
test('Clicking Preview Pane button hides preview', async ({ page }) => {
|
||||
const previewButton = page.getByTestId('preview-pane-button');
|
||||
const previewFrame = page.getByTitle('challenge preview');
|
||||
|
||||
await previewButton.click();
|
||||
await expect(previewFrame).toBeHidden();
|
||||
});
|
||||
|
||||
test('Clicking Preview Portal button opens the preview in a new tab', async ({
|
||||
page
|
||||
}) => {
|
||||
const previewPortalButton = page.getByRole('button', {
|
||||
name: translations.aria['move-preview-to-new-window']
|
||||
});
|
||||
const browserContext = page.context();
|
||||
|
||||
const [newPage] = await Promise.all([
|
||||
browserContext.waitForEvent('page'),
|
||||
previewPortalButton.click()
|
||||
]);
|
||||
|
||||
await newPage.waitForLoadState();
|
||||
|
||||
await expect(newPage).toHaveURL('about:blank');
|
||||
|
||||
await newPage.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Mobile view', () => {
|
||||
test.skip(({ isMobile }) => !isMobile, 'Only test on mobile');
|
||||
|
||||
test('Action row is hidden', async ({ page }) => {
|
||||
await page.goto(
|
||||
'/learn/responsive-web-design-v9/lab-survey-form/build-a-survey-form'
|
||||
);
|
||||
const actionRow = page.getByTestId('action-row');
|
||||
await expect(actionRow).toBeHidden();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { SuperBlocks } from '@freecodecamp/shared/config/curriculum';
|
||||
|
||||
test.describe('Archive Page', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/learn/archive');
|
||||
});
|
||||
|
||||
test('links back to the current curriculum', async ({ page }) => {
|
||||
const newCurriculumLink = page.locator(
|
||||
'.archived-warning a[href="/learn"]'
|
||||
);
|
||||
await newCurriculumLink.click();
|
||||
|
||||
await expect(page).toHaveURL('/learn');
|
||||
});
|
||||
|
||||
test('opens an archived superblock from the archive map', async ({
|
||||
page
|
||||
}) => {
|
||||
const archivedSuperBlockPath = `/learn/${SuperBlocks.RespWebDesignNew}/`;
|
||||
const archivedSuperBlockLink = page.locator(
|
||||
`a[href="${archivedSuperBlockPath}"]`
|
||||
);
|
||||
|
||||
await archivedSuperBlockLink.click();
|
||||
|
||||
await expect(page).toHaveURL(archivedSuperBlockPath);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const locations = {
|
||||
index:
|
||||
'learn/back-end-development-and-apis/managing-packages-with-npm/' +
|
||||
'how-to-use-package-json-the-core-of-any-node-js-project-or-npm-package'
|
||||
};
|
||||
|
||||
const unhandledErrorMessage = 'Something is not quite right';
|
||||
const runningOutput = '// running tests';
|
||||
const finishedOutput = '// tests completed';
|
||||
|
||||
test.describe('Backend challenge', () => {
|
||||
test('renders', async ({ page }) => {
|
||||
await page.goto(locations.index);
|
||||
await expect(page).toHaveTitle(
|
||||
'Managing Packages with NPM - How to Use package.json, the Core of Any Node.js Project or npm Package | Learn | freeCodeCamp.org'
|
||||
);
|
||||
});
|
||||
|
||||
test('does not generate unhandled errors on submission', async ({ page }) => {
|
||||
await page.goto(locations.index);
|
||||
await page.fill('input[name="solution"]', 'https://example.com');
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
await expect(page.getByText(runningOutput)).toContainText(finishedOutput);
|
||||
|
||||
await expect(page.getByText(unhandledErrorMessage)).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Block Navigation - Hash Updates', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test('should update URL hash when clicking on a challenge link in a grid layout', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(
|
||||
'/learn/javascript-algorithms-and-data-structures-v8/#learn-introductory-javascript-by-building-a-pyramid-generator'
|
||||
);
|
||||
|
||||
// Verify the hash is set correctly
|
||||
await expect(page).toHaveURL(
|
||||
/#learn-introductory-javascript-by-building-a-pyramid-generator$/
|
||||
);
|
||||
|
||||
// Click on step 1 in the grid - the accessible name includes the sr-only text
|
||||
const step1Link = page.getByRole('link', { name: 'Step 1 Not Passed' });
|
||||
await expect(step1Link).toBeVisible();
|
||||
await step1Link.click();
|
||||
|
||||
// Wait for navigation
|
||||
await page.waitForURL(/step-1$/);
|
||||
|
||||
// Go back to verify the hash persists in history
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(
|
||||
/#learn-introductory-javascript-by-building-a-pyramid-generator$/
|
||||
);
|
||||
});
|
||||
|
||||
test('should update URL hash when clicking on a certification project', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/learn/javascript-algorithms-and-data-structures-v8');
|
||||
|
||||
// Click on the certification project link
|
||||
const projectLink = page.getByRole('link', {
|
||||
name: 'Build a Palindrome Checker Project Certification Project, Not completed'
|
||||
});
|
||||
await expect(projectLink).toBeVisible();
|
||||
await projectLink.click();
|
||||
|
||||
// Wait for navigation
|
||||
await page.waitForURL(/build-a-palindrome-checker$/);
|
||||
|
||||
// Go back to verify the hash persists in history
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/#build-a-palindrome-checker-project$/);
|
||||
});
|
||||
|
||||
test('should update URL hash when clicking on a challenge in accordion layout (v9)', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/learn/javascript-v9');
|
||||
|
||||
await page.getByRole('button', { name: /^Build a Greeting Bot/ }).click();
|
||||
|
||||
// Click on step 1 in the accordion
|
||||
const step1Link = page.getByRole('link', { name: 'Step 1 Not Passed' });
|
||||
await expect(step1Link).toBeVisible();
|
||||
await step1Link.click();
|
||||
|
||||
// Wait for navigation
|
||||
await page.waitForURL(/step-1$/);
|
||||
|
||||
// Go back to verify the hash persists in history
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/#workshop-greeting-bot$/);
|
||||
});
|
||||
|
||||
test('should update URL hash when clicking on a certification project in accordion layout (v9)', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/learn/javascript-v9');
|
||||
|
||||
// Click on the certification project link
|
||||
const projectLink = page.getByRole('link', {
|
||||
name: 'Build a Markdown to HTML Converter'
|
||||
});
|
||||
await expect(projectLink).toBeVisible();
|
||||
await projectLink.click();
|
||||
|
||||
// Wait for navigation
|
||||
await page.waitForURL(/build-a-markdown-to-html-converter$/);
|
||||
|
||||
// Go back to verify the hash persists in history
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/#lab-markdown-to-html-converter$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Prevent the mobile app modal from appearing and interfering with the test
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('hideMobileAppModal', 'true');
|
||||
});
|
||||
await page.goto(
|
||||
'/learn/responsive-web-design-v9/workshop-cat-photo-app/step-2'
|
||||
);
|
||||
});
|
||||
|
||||
test.describe('Challenge Breadcrumb Tests', () => {
|
||||
test('should display correctly', async ({ page, isMobile }) => {
|
||||
const mobileBreadcrumb = async () => {
|
||||
const testId = 'breadcrumb-mobile';
|
||||
const superBlock = page.getByTestId(testId).getByRole('listitem').first();
|
||||
await expect(superBlock).toBeVisible();
|
||||
|
||||
const superBlockLink = superBlock.getByRole('link', {
|
||||
name: 'Responsive Web Design Certification'
|
||||
});
|
||||
await expect(superBlockLink).toBeVisible();
|
||||
await expect(superBlockLink).toHaveAttribute(
|
||||
'href',
|
||||
'/learn/responsive-web-design-v9'
|
||||
);
|
||||
|
||||
const block = page.getByTestId(testId).getByRole('listitem').last();
|
||||
await expect(superBlock).toBeVisible();
|
||||
|
||||
const blockLink = block.getByRole('link', {
|
||||
name: 'Build a Cat Photo App'
|
||||
});
|
||||
await expect(blockLink).toBeVisible();
|
||||
await expect(blockLink).toHaveAttribute(
|
||||
'href',
|
||||
'/learn/responsive-web-design-v9/#workshop-cat-photo-app'
|
||||
);
|
||||
};
|
||||
|
||||
if (!isMobile) {
|
||||
await expect(page.getByTestId('breadcrumb-mobile')).toBeHidden();
|
||||
await expect(page.getByTestId('breadcrumb-desktop')).toBeVisible();
|
||||
|
||||
await page.setViewportSize({
|
||||
width: 766,
|
||||
height: 1080
|
||||
});
|
||||
}
|
||||
|
||||
await expect(page.getByTestId('breadcrumb-desktop')).toBeHidden();
|
||||
await mobileBreadcrumb();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { execSync } from 'child_process';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.describe('Public profile certifications', () => {
|
||||
test('Should show claimed certifications if the username has all lowercase characters', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/certifieduser');
|
||||
|
||||
await expect(
|
||||
page.getByRole('link', { name: /View.+Certification/ })
|
||||
).toHaveCount(25);
|
||||
});
|
||||
|
||||
test('Should show claimed certifications if the username includes uppercase characters', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/certifieduser');
|
||||
|
||||
await page.getByRole('button', { name: 'Edit my profile' }).click();
|
||||
|
||||
await page.getByLabel('Username').fill('CertifiedBoozer');
|
||||
await page.getByRole('button', { name: 'Save' }).nth(0).click();
|
||||
await expect(page.getByTestId('flash-message')).toContainText(
|
||||
/We have updated your username to/
|
||||
);
|
||||
await page.goto('/certifiedboozer');
|
||||
|
||||
await page.waitForURL('/certifiedboozer');
|
||||
await expect(
|
||||
page.getByRole('link', { name: /View.+Certification/ })
|
||||
).toHaveCount(25);
|
||||
});
|
||||
|
||||
test.afterAll(() => {
|
||||
execSync('node ../tools/scripts/seed/seed-demo-user --certified-user');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import translations from '../client/i18n/locales/english/translations.json';
|
||||
import { alertToBeVisible } from './utils/alerts';
|
||||
|
||||
test.describe('Certification page', () => {
|
||||
test('renders a non-Microsoft certification route', async ({ page }) => {
|
||||
await page.goto('/certification/certifieduser/responsive-web-design');
|
||||
|
||||
await expect(page.locator('[data-testid="cert-wrapper"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="cert-links"]')).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-testid="donation-section"]')
|
||||
).toBeVisible();
|
||||
await expect(page.locator('[data-testid="project-links"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('renders a Microsoft certification route', async ({ page }) => {
|
||||
await page.goto(
|
||||
'/certification/certifieduser/foundational-c-sharp-with-microsoft'
|
||||
);
|
||||
|
||||
await expect(page.locator('[data-testid="cert-wrapper"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="cert-links"]')).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-testid="donation-section"]')
|
||||
).toBeVisible();
|
||||
await expect(page.locator('[data-testid="project-links"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Invalid certification page', () => {
|
||||
const testInvalidCertification = async ({ page }: { page: Page }) => {
|
||||
await page.goto('/certification/certifieduser/invalid-certification');
|
||||
await expect(page).toHaveURL('/');
|
||||
await alertToBeVisible(page, translations.flash['certificate-missing']);
|
||||
};
|
||||
test.describe('for authenticated user', () => {
|
||||
test(
|
||||
'it should redirect to / and display an error message',
|
||||
testInvalidCertification
|
||||
);
|
||||
});
|
||||
|
||||
test.describe('for unauthenticated user', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
test(
|
||||
'it should redirect to / and display an error message',
|
||||
testInvalidCertification
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,343 @@
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
|
||||
import translations from '../client/i18n/locales/english/translations.json';
|
||||
import { clearEditor, focusEditor, getEditors } from './utils/editor';
|
||||
import { alertToBeVisible } from './utils/alerts';
|
||||
|
||||
interface ChallengeTest {
|
||||
text: string;
|
||||
testString: string;
|
||||
}
|
||||
|
||||
interface PageData {
|
||||
result: {
|
||||
data: {
|
||||
challengeNode: {
|
||||
challenge: { tests: ChallengeTest[] };
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const expectToRenderResetModal = async (page: Page) => {
|
||||
await expect(
|
||||
page.getByRole('dialog', { name: translations.learn.reset })
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: translations.buttons.close
|
||||
})
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: translations.learn.reset
|
||||
})
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText(translations.learn['reset-warn-2'])
|
||||
).toBeVisible();
|
||||
};
|
||||
|
||||
test('should render the modal content correctly', async ({ page }) => {
|
||||
await page.goto(
|
||||
'/learn/responsive-web-design-v9/workshop-cat-photo-app/step-3'
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: translations.buttons.reset }).click();
|
||||
|
||||
await expectToRenderResetModal(page);
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: translations.buttons['reset-lesson']
|
||||
})
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText(
|
||||
'Are you sure you wish to reset this lesson (Step 3)? The code editors and tests will be reset.'
|
||||
)
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('User can reset challenge', async ({ page, isMobile, browserName }) => {
|
||||
const initialText = ' <h2>Cat Photos</h2>';
|
||||
const initialEditorText = page
|
||||
.getByTestId('editor-container-indexhtml')
|
||||
.getByText(initialText);
|
||||
|
||||
const updatedText = 'Only Dogs';
|
||||
const updatedEditorText = page
|
||||
.getByTestId('editor-container-indexhtml')
|
||||
.getByText(updatedText);
|
||||
|
||||
await page.goto(
|
||||
'/learn/responsive-web-design-v9/workshop-cat-photo-app/step-3'
|
||||
);
|
||||
|
||||
// Building the preview can take a while
|
||||
await expect(initialEditorText).toBeVisible();
|
||||
|
||||
// Modify the text in the editor pane, clearing first, otherwise the existing
|
||||
// text will be selected before typing
|
||||
await focusEditor({ page, isMobile });
|
||||
await clearEditor({ page, browserName });
|
||||
await getEditors(page).fill(updatedText);
|
||||
await expect(updatedEditorText).toBeVisible();
|
||||
|
||||
// Run the tests so the lower jaw updates (later we confirm that the update
|
||||
// are reset)
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: translations.buttons['check-code']
|
||||
})
|
||||
.click();
|
||||
|
||||
// Reset the challenge
|
||||
await page.getByRole('button', { name: translations.buttons.reset }).click();
|
||||
await page
|
||||
.getByRole('button', { name: translations.buttons['reset-lesson'] })
|
||||
.click();
|
||||
|
||||
// Check it's back to the initial state
|
||||
await expect(initialEditorText).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(translations.learn['sorry-keep-trying'])
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
test.describe('When the user is not logged in', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test('User can reset classic challenge', async ({ page, isMobile }) => {
|
||||
const challengePath =
|
||||
'/learn/rosetta-code/rosetta-code-challenges/100-doors';
|
||||
|
||||
// Intercept Gatsby page-data and inject a mock test that always passes
|
||||
await page.route(
|
||||
`**/page-data${challengePath}/page-data.json`,
|
||||
async route => {
|
||||
const response = await route.fetch();
|
||||
const body = await response.text();
|
||||
|
||||
const pageData = JSON.parse(body) as PageData;
|
||||
pageData.result.data.challengeNode.challenge.tests = [
|
||||
{
|
||||
text: 'Mock test',
|
||||
testString: 'assert(true)'
|
||||
}
|
||||
];
|
||||
|
||||
await route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(pageData)
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
await page.goto(challengePath);
|
||||
|
||||
const challengeSolution = 'test code';
|
||||
await focusEditor({ page, isMobile });
|
||||
await getEditors(page).fill(challengeSolution);
|
||||
|
||||
const submitButton = isMobile
|
||||
? page.getByRole('button', { name: translations.buttons.run })
|
||||
: page.getByRole('button', { name: translations.buttons['check-code'] });
|
||||
|
||||
await submitButton.click();
|
||||
|
||||
await expect(
|
||||
page.locator('.view-lines').getByText(challengeSolution)
|
||||
).toBeVisible();
|
||||
|
||||
// Completion dialog shows up
|
||||
await expect(
|
||||
page.getByText(translations.buttons['submit-continue'])
|
||||
).toBeVisible();
|
||||
|
||||
// Close the dialog
|
||||
await page
|
||||
.getByRole('button', { name: translations.buttons.close })
|
||||
.click();
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: translations.buttons.reset })
|
||||
.click();
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: translations.buttons['reset-lesson'] })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.locator('.view-lines').getByText(challengeSolution)
|
||||
).not.toBeVisible();
|
||||
await expect(
|
||||
page.getByText(translations.buttons['go-to-next'])
|
||||
).not.toBeVisible();
|
||||
await expect(
|
||||
page.getByText(translations.learn['tests-completed'])
|
||||
).not.toBeVisible();
|
||||
|
||||
if (isMobile) {
|
||||
await page.getByText(translations.learn['editor-tabs'].console).click();
|
||||
}
|
||||
|
||||
await expect(
|
||||
page.getByText(translations.learn['test-output'])
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test('should close when the user clicks the close button', async ({ page }) => {
|
||||
await page.goto(
|
||||
'/learn/responsive-web-design-v9/workshop-cat-photo-app/step-3'
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: translations.buttons.reset }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('dialog', { name: translations.learn.reset })
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: translations.buttons.close
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('dialog', { name: translations.learn.reset })
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
test('User can reset on a multi-file project', async ({
|
||||
page,
|
||||
isMobile,
|
||||
browserName
|
||||
}) => {
|
||||
const sampleText = 'function palindrome() { return true; }';
|
||||
|
||||
await page.goto(
|
||||
'/learn/javascript-v9/lab-palindrome-checker/build-a-palindrome-checker'
|
||||
);
|
||||
|
||||
await focusEditor({ page, isMobile });
|
||||
await clearEditor({ page, browserName });
|
||||
await getEditors(page).fill(sampleText);
|
||||
await expect(page.getByText(sampleText)).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: translations.buttons.revert }).click();
|
||||
|
||||
await expectToRenderResetModal(page);
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: translations.buttons['revert-to-saved-code']
|
||||
})
|
||||
).toBeVisible();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: translations.buttons['revert-to-saved-code']
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(page.getByText(translations.learn['revert-warn'])).toBeVisible();
|
||||
|
||||
await expect(page.getByText(sampleText)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test.describe('Signed in user', () => {
|
||||
test.use({ storageState: 'playwright/.auth/development-user.json' });
|
||||
|
||||
test.beforeEach(() => {
|
||||
execSync('node ../tools/scripts/seed/seed-demo-user');
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
execSync('node ../tools/scripts/seed/seed-demo-user --certified-user');
|
||||
});
|
||||
|
||||
test('User can reset on a multi-file project after reloading and saving', async ({
|
||||
page,
|
||||
isMobile,
|
||||
browserName
|
||||
}) => {
|
||||
test.setTimeout(60000);
|
||||
const savedText = 'function palindrome() { return true; }';
|
||||
const updatedText = 'function palindrome() { return false; }';
|
||||
|
||||
await page.goto(
|
||||
'/learn/javascript-v9/lab-palindrome-checker/build-a-palindrome-checker'
|
||||
);
|
||||
|
||||
// This first edit should reappear after the reset
|
||||
await focusEditor({ page, isMobile });
|
||||
await clearEditor({ page, browserName });
|
||||
await getEditors(page).fill(savedText);
|
||||
await page.keyboard.press('Control+S');
|
||||
await alertToBeVisible(page, translations.flash['code-saved']);
|
||||
|
||||
await page.reload();
|
||||
|
||||
// This second edit should be reset
|
||||
await focusEditor({ page, isMobile });
|
||||
await clearEditor({ page, browserName });
|
||||
await getEditors(page).fill(updatedText);
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: translations.buttons.revert })
|
||||
.click();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: translations.buttons['revert-to-saved-code']
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(page.getByText(updatedText)).not.toBeVisible();
|
||||
await expect(page.getByText(savedText)).toBeVisible();
|
||||
});
|
||||
|
||||
test('User can reset on a multi-file project without reloading', async ({
|
||||
page,
|
||||
isMobile,
|
||||
browserName
|
||||
}) => {
|
||||
test.setTimeout(60000);
|
||||
const savedText = 'function palindrome() { return true; }';
|
||||
const updatedText = 'function palindrome() { return false; }';
|
||||
|
||||
await page.goto(
|
||||
'/learn/javascript-v9/lab-palindrome-checker/build-a-palindrome-checker'
|
||||
);
|
||||
|
||||
// This first edit should reappear after the reset
|
||||
await focusEditor({ page, isMobile });
|
||||
await clearEditor({ page, browserName });
|
||||
await getEditors(page).fill(savedText);
|
||||
await page.keyboard.press('Control+S');
|
||||
await alertToBeVisible(page, translations.flash['code-saved']);
|
||||
|
||||
// This second edit should be reset
|
||||
await focusEditor({ page, isMobile });
|
||||
await clearEditor({ page, browserName });
|
||||
await getEditors(page).fill(updatedText);
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: translations.buttons.revert })
|
||||
.click();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: translations.buttons['revert-to-saved-code']
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(page.getByText(updatedText)).not.toBeVisible();
|
||||
await expect(page.getByText(savedText)).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
import translations from '../client/i18n/locales/english/translations.json';
|
||||
import { clearEditor, focusEditor, getEditors } from './utils/editor';
|
||||
|
||||
test.describe('when reloading the page', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const pageUsingEditableRegionInTests =
|
||||
'/learn/2022/responsive-web-design/learn-basic-css-by-building-a-cafe-menu/step-14';
|
||||
await page.goto(pageUsingEditableRegionInTests);
|
||||
});
|
||||
// This is quite brittle. If it breaks, try to come up with a unit test instead.
|
||||
|
||||
test('should keep the editable content for testing', async ({
|
||||
page,
|
||||
isMobile,
|
||||
browserName
|
||||
}) => {
|
||||
await focusEditor({ page, isMobile });
|
||||
await clearEditor({ page, browserName });
|
||||
// For some reason, fill doesn't work properly on firefox if there are new lines
|
||||
// in the text, hence one line:
|
||||
const solution = `h1, h2, p { text-align: center; }`;
|
||||
|
||||
await getEditors(page).fill(solution);
|
||||
const editorTextLocator = page
|
||||
.getByTestId('editor-container-stylescss')
|
||||
.getByText(solution);
|
||||
await expect(editorTextLocator).toBeVisible();
|
||||
|
||||
// save the code
|
||||
await page.keyboard.down('Control');
|
||||
await page.keyboard.press('S');
|
||||
|
||||
await page.reload();
|
||||
|
||||
await expect(editorTextLocator).toBeVisible();
|
||||
|
||||
// check the tests pass
|
||||
await page.keyboard.down('Control');
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
await expect(
|
||||
page.getByText(translations.learn['congratulations-code-passes'])
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
import { getEditors } from './utils/editor';
|
||||
|
||||
test.describe('Challenge with editor', function () {
|
||||
test.skip(({ isMobile }) => isMobile);
|
||||
test('the shortcut "Ctrl + S" saves the code', async ({ page }) => {
|
||||
await page.goto(
|
||||
'/learn/2022/responsive-web-design/learn-html-by-building-a-cat-photo-app/step-2'
|
||||
);
|
||||
|
||||
const editor = getEditors(page);
|
||||
|
||||
await editor.fill('Something funny');
|
||||
await page.keyboard.down('Control');
|
||||
await page.keyboard.press('S');
|
||||
|
||||
await expect(
|
||||
page.getByText(
|
||||
"Saved! Your code was saved to your browser's local storage."
|
||||
)
|
||||
).toBeVisible();
|
||||
|
||||
await page.reload();
|
||||
|
||||
// check editor content
|
||||
await expect(editor).toHaveValue(/Something funny/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
|
||||
import tributePage from './fixtures/tribute-page.json';
|
||||
|
||||
import { authedRequest } from './utils/request';
|
||||
|
||||
const unlockedProfile = {
|
||||
isLocked: false,
|
||||
showAbout: true,
|
||||
showCerts: true,
|
||||
showDonation: true,
|
||||
showHeatMap: true,
|
||||
showLocation: true,
|
||||
showName: true,
|
||||
showPoints: true,
|
||||
showPortfolio: true,
|
||||
showExperience: true,
|
||||
showTimeLine: true
|
||||
};
|
||||
|
||||
async function expectPreviewToBeShown(page: Page) {
|
||||
await page
|
||||
.getByRole('button', { name: 'View Solution for Build a Tribute Page' })
|
||||
.first()
|
||||
.click();
|
||||
await page.getByRole('menuitem', { name: 'View Project' }).click();
|
||||
const modalHeading = page.getByRole('heading', {
|
||||
name: 'Build a Tribute Page',
|
||||
exact: true
|
||||
});
|
||||
await expect(modalHeading).toBeVisible();
|
||||
|
||||
const projectPreview = page.frameLocator('#fcc-project-preview-frame');
|
||||
await expect(projectPreview.getByText('Tribute page text')).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe('Completed project preview', () => {
|
||||
test.use({ storageState: 'playwright/.auth/development-user.json' });
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
execSync('node ../tools/scripts/seed/seed-demo-user');
|
||||
|
||||
await authedRequest({
|
||||
request,
|
||||
method: 'post',
|
||||
endpoint: '/modern-challenge-completed',
|
||||
data: {
|
||||
id: tributePage.id,
|
||||
challengeType: 14,
|
||||
files: [tributePage.htmlFile, tributePage.cssFile]
|
||||
}
|
||||
});
|
||||
|
||||
await authedRequest({
|
||||
request,
|
||||
endpoint: '/update-my-profileui',
|
||||
method: 'put',
|
||||
data: {
|
||||
profileUI: unlockedProfile
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('it should be viewable on the timeline', async ({ page }) => {
|
||||
await page.goto('/developmentuser');
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: '@developmentuser' })
|
||||
).toBeVisible();
|
||||
|
||||
await expectPreviewToBeShown(page);
|
||||
});
|
||||
|
||||
test('it should be viewable on the settings page', async ({ page }) => {
|
||||
await page.goto('/settings');
|
||||
|
||||
await expectPreviewToBeShown(page);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import translations from '../client/i18n/locales/english/translations.json';
|
||||
import { authedRequest } from './utils/request';
|
||||
import { allowTrailingSlash } from './utils/url';
|
||||
|
||||
const nextChallengeURL =
|
||||
'/learn/data-analysis-with-python/data-analysis-with-python-projects/demographic-data-analyzer';
|
||||
|
||||
test.beforeAll(() => {
|
||||
execSync('node ../tools/scripts/seed/seed-demo-user --certified-user');
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(
|
||||
'/learn/data-analysis-with-python/data-analysis-with-python-projects/mean-variance-standard-deviation-calculator'
|
||||
);
|
||||
await page.getByLabel('Solution Link').fill('https://example.com');
|
||||
await page
|
||||
.getByRole('button', { name: "I've completed this challenge" })
|
||||
.click();
|
||||
});
|
||||
|
||||
test.describe('Challenge Completion Modal Tests (Signed Out)', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
test('should redirect to /learn after sign in', async ({ page }) => {
|
||||
await page
|
||||
.getByRole('link', { name: translations.learn['sign-in-save'] })
|
||||
.click();
|
||||
await expect(page).toHaveURL(allowTrailingSlash('/learn'));
|
||||
});
|
||||
|
||||
test('should redirect to next challenge', async ({ page }) => {
|
||||
await page
|
||||
.getByRole('button', { name: translations.buttons['go-to-next'] })
|
||||
.click();
|
||||
await expect(page).toHaveURL(nextChallengeURL);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Challenge Completion Modal Tests (Signed In)', () => {
|
||||
test('should close the modal after user presses escape while having keyboard shortcuts disabled', async ({
|
||||
page,
|
||||
request
|
||||
}) => {
|
||||
await authedRequest({
|
||||
request,
|
||||
endpoint: 'update-my-keyboard-shortcuts',
|
||||
method: 'put',
|
||||
data: {
|
||||
keyboardShortcuts: false
|
||||
}
|
||||
});
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('should close the modal after user presses escape while having keyboard shortcuts enabled', async ({
|
||||
page,
|
||||
request
|
||||
}) => {
|
||||
await authedRequest({
|
||||
request,
|
||||
endpoint: 'update-my-keyboard-shortcuts',
|
||||
method: 'put',
|
||||
data: {
|
||||
keyboardShortcuts: true
|
||||
}
|
||||
});
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should submit and go to the next challenge when the user clicks the submit button', async ({
|
||||
page
|
||||
}) => {
|
||||
await page
|
||||
.getByRole('button', { name: translations.buttons['submit-and-go'] })
|
||||
.click();
|
||||
await expect(page).toHaveURL(nextChallengeURL);
|
||||
});
|
||||
|
||||
test('should submit and go to the next challenge when the user presses Ctrl + Enter', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.keyboard.press('Control+Enter');
|
||||
await expect(page).toHaveURL(nextChallengeURL);
|
||||
});
|
||||
|
||||
test('should submit and go to the next challenge when the user presses Command + Enter', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.keyboard.press('Meta+Enter');
|
||||
await expect(page).toHaveURL(nextChallengeURL);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Content outline - Desktop', () => {
|
||||
test.skip(({ isMobile }) => isMobile, 'Only test on desktop');
|
||||
|
||||
test('shows section headings without a top menu header item', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(
|
||||
'/learn/responsive-web-design-v9/review-semantic-html/review-semantic-html'
|
||||
);
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Semantic HTML Review' })
|
||||
).toBeVisible();
|
||||
|
||||
const toggleButton = page.getByRole('button', { name: 'Outline' });
|
||||
await toggleButton.click();
|
||||
await expect(toggleButton).toHaveAttribute('aria-expanded', 'true');
|
||||
|
||||
const outlinePanel = page.getByRole('navigation', {
|
||||
name: 'Content outline'
|
||||
});
|
||||
await expect(outlinePanel).toBeVisible();
|
||||
|
||||
const outlineItems = outlinePanel.getByRole('listitem');
|
||||
expect(await outlineItems.count()).toBeGreaterThan(0);
|
||||
|
||||
await expect(
|
||||
outlinePanel.getByRole('link', { name: 'Semantic HTML Review' })
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('keeps active nav item in view while scrolling', async ({ page }) => {
|
||||
await page.goto(
|
||||
'/learn/responsive-web-design-v9/review-semantic-html/review-semantic-html'
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: 'Outline' }).click();
|
||||
|
||||
const outlinePanel = page.getByRole('navigation', {
|
||||
name: 'Content outline'
|
||||
});
|
||||
await expect(outlinePanel).toBeVisible();
|
||||
|
||||
const firstOutlineLink = outlinePanel.locator('a').first();
|
||||
await firstOutlineLink.click();
|
||||
await expect(firstOutlineLink).toHaveClass(/active/);
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.scrollTo({
|
||||
top: document.body.scrollHeight,
|
||||
behavior: 'auto'
|
||||
});
|
||||
});
|
||||
|
||||
const activeLink = outlinePanel
|
||||
.locator('.content-outline-link.active')
|
||||
.first();
|
||||
await expect(activeLink).toBeVisible();
|
||||
|
||||
const inView = await activeLink.evaluate(item => {
|
||||
const panel = item.closest('nav');
|
||||
if (!panel) return false;
|
||||
|
||||
const panelRect = panel.getBoundingClientRect();
|
||||
const itemRect = item.getBoundingClientRect();
|
||||
return (
|
||||
itemRect.top >= panelRect.top && itemRect.bottom <= panelRect.bottom
|
||||
);
|
||||
});
|
||||
|
||||
expect(inView).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Content outline - Mobile', () => {
|
||||
test.skip(({ isMobile }) => !isMobile, 'Only test on mobile');
|
||||
|
||||
test('sidebar closes on mobile when an item is clicked', async ({ page }) => {
|
||||
await page.goto(
|
||||
'/learn/responsive-web-design-v9/review-semantic-html/review-semantic-html'
|
||||
);
|
||||
|
||||
const toggleButton = page.getByRole('button', { name: 'Outline' });
|
||||
await toggleButton.click();
|
||||
await expect(toggleButton).toHaveAttribute('aria-expanded', 'true');
|
||||
|
||||
const outlinePanel = page.getByRole('navigation', {
|
||||
name: 'Content outline'
|
||||
});
|
||||
await expect(outlinePanel).toBeVisible();
|
||||
|
||||
const firstLink = outlinePanel.locator('a').first();
|
||||
await firstLink.click();
|
||||
|
||||
// panel should hide after click
|
||||
await expect(outlinePanel).toBeHidden();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
getTodayUsCentral,
|
||||
formatDisplayDate
|
||||
} from '../client/src/components/daily-coding-challenge/helpers';
|
||||
|
||||
const dateRouteRe = /.*\/daily-coding-challenge\/date\/.*/;
|
||||
|
||||
const todayUsCentral = getTodayUsCentral();
|
||||
|
||||
const todayMidnight = `${todayUsCentral}T00:00:00.000Z`;
|
||||
|
||||
const mockApiChallenge = {
|
||||
id: 'test-challenge-id',
|
||||
challengeNumber: 1,
|
||||
title: 'Test title',
|
||||
date: todayMidnight,
|
||||
description: 'Test description',
|
||||
javascript: {
|
||||
tests: [
|
||||
{
|
||||
text: 'Test text',
|
||||
testString: 'assert.strictEqual(true, true);'
|
||||
}
|
||||
],
|
||||
challengeFiles: [
|
||||
{
|
||||
fileKey: 'scriptjs',
|
||||
contents: '// JavaScript seed code'
|
||||
}
|
||||
]
|
||||
},
|
||||
python: {
|
||||
tests: [
|
||||
{
|
||||
text: 'Test text',
|
||||
testString: '({test: () => { runPython(`assert True == True`)}})'
|
||||
}
|
||||
],
|
||||
challengeFiles: [
|
||||
{
|
||||
fileKey: 'mainpy',
|
||||
contents: '# Python seed code'
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
// Temporarily disabled
|
||||
// const runChallengeTest = async (page: Page, isMobile: boolean) => {
|
||||
// if (isMobile) {
|
||||
// await page.getByRole('tab', { name: 'Console' }).click();
|
||||
// await page.getByText('Run').click();
|
||||
// } else {
|
||||
// await page.getByText('Run the Tests (Ctrl + Enter)').click();
|
||||
// }
|
||||
// };
|
||||
|
||||
test.describe('Daily Coding Challenges', () => {
|
||||
test('should redirect to archive for invalid date', async ({ page }) => {
|
||||
await page.goto('/learn/daily-coding-challenge/invalid-date');
|
||||
await expect(page).toHaveURL('/learn/daily-coding-challenge/archive');
|
||||
});
|
||||
|
||||
test('should load and display a daily coding challenge with a valid date, and should be able to switch between JavaScript and Python', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.route(dateRouteRe, async route => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
json: mockApiChallenge
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`/learn/daily-coding-challenge/${todayUsCentral}`);
|
||||
|
||||
const leftBreadcrumb = page.getByRole('link', {
|
||||
name: /daily coding challenge/i
|
||||
});
|
||||
await expect(leftBreadcrumb).toBeVisible();
|
||||
await expect(leftBreadcrumb).toHaveAttribute(
|
||||
'href',
|
||||
'/learn/daily-coding-challenge/archive'
|
||||
);
|
||||
|
||||
const rightBreadcrumb = page.getByRole('link', {
|
||||
name: `${formatDisplayDate(todayUsCentral)}`
|
||||
});
|
||||
await expect(rightBreadcrumb).toBeVisible();
|
||||
await expect(rightBreadcrumb).toHaveAttribute(
|
||||
'href',
|
||||
'/learn/daily-coding-challenge/archive'
|
||||
);
|
||||
|
||||
await expect(page.getByText('Test title')).toBeVisible();
|
||||
|
||||
await expect(page.getByText('Test description')).toBeVisible();
|
||||
|
||||
// Language buttons
|
||||
await expect(
|
||||
page.getByRole('button', { name: /javascript/i })
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /python/i })).toBeVisible();
|
||||
|
||||
// Should show JS UI by default
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: /script.js/i })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: /main.py/i })
|
||||
).not.toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /console/i })).toBeVisible();
|
||||
await expect(page.getByTestId('preview-pane-button')).not.toBeVisible();
|
||||
await expect(page.locator("div[role='code'].monaco-editor")).toContainText(
|
||||
'// JavaScript seed code'
|
||||
);
|
||||
|
||||
// Show Python UI after changing language
|
||||
await page.getByRole('button', { name: /python/i }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: /main.py/i })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: /script.js/i })
|
||||
).not.toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /console/i })).toBeVisible();
|
||||
await expect(page.getByTestId('preview-pane-button')).toBeVisible();
|
||||
await expect(page.locator("div[role='code'].monaco-editor")).toContainText(
|
||||
'# Python seed code'
|
||||
);
|
||||
|
||||
await page.goto(`/learn/daily-coding-challenge/${todayUsCentral}`);
|
||||
|
||||
await expect(page.getByRole('button', { name: /main.py/i })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Daily Coding Challenge Archive', () => {
|
||||
test('/learn/daily-coding-challenge should redirect to archive', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/learn/daily-coding-challenge');
|
||||
await expect(page).toHaveURL('/learn/daily-coding-challenge/archive');
|
||||
});
|
||||
|
||||
test('/learn/daily-coding-challenge/ should redirect to archive', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/learn/daily-coding-challenge/');
|
||||
await expect(page).toHaveURL('/learn/daily-coding-challenge/archive');
|
||||
});
|
||||
|
||||
test('/learn/daily-coding-challenge/path-1/path2 should redirect to archive', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/learn/daily-coding-challenge/path-1/path2');
|
||||
await expect(page).toHaveURL('/learn/daily-coding-challenge/archive');
|
||||
});
|
||||
});
|
||||
|
||||
// Temporarily disabled
|
||||
// test.describe('Daily code challenge solution can be downloaded', () => {
|
||||
// test('Downloaded solution files are named by challenge number', async ({
|
||||
// page,
|
||||
// isMobile
|
||||
// }) => {
|
||||
// await page.route(/.*\/daily-coding-challenge\/date\/.*/, async route => {
|
||||
// await route.fulfill({
|
||||
// status: 200,
|
||||
// headers: { 'Content-Type': 'application/json' },
|
||||
// json: mockApiChallenge
|
||||
// });
|
||||
// });
|
||||
|
||||
// await page.goto(`/learn/daily-coding-challenge/${todayUsCentral}`);
|
||||
// await runChallengeTest(page, isMobile);
|
||||
// await expect(page.getByRole('dialog')).toBeVisible({ timeout: 15000 });
|
||||
// await expect(
|
||||
// page.getByRole('link', { name: 'Download my solution' })
|
||||
// ).toBeVisible({ timeout: 15000 });
|
||||
// const [download] = await Promise.all([
|
||||
// page.waitForEvent('download'),
|
||||
// page.getByRole('link', { name: 'Download my solution' }).click()
|
||||
// ]);
|
||||
// const suggestedFileName = download.suggestedFilename();
|
||||
// await download.saveAs(suggestedFileName);
|
||||
// expect(fs.existsSync(suggestedFileName)).toBeTruthy();
|
||||
// expect(suggestedFileName).toBe('challenge-1.txt');
|
||||
// });
|
||||
// });
|
||||
@@ -0,0 +1,64 @@
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
import translations from '../client/i18n/locales/english/translations.json';
|
||||
|
||||
const execP = promisify(exec);
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/settings');
|
||||
});
|
||||
|
||||
test.afterAll(
|
||||
async () =>
|
||||
await Promise.all([
|
||||
execP('node ../tools/scripts/seed/seed-demo-user --certified-user'),
|
||||
execP('node ../tools/scripts/seed/seed-surveys'),
|
||||
execP('node ../tools/scripts/seed/seed-ms-username')
|
||||
])
|
||||
);
|
||||
|
||||
test.describe('Delete Modal component', () => {
|
||||
test('should close the modal and sign the user out after they fill in the verify input text and click delete', async ({
|
||||
page
|
||||
}) => {
|
||||
await page
|
||||
.getByRole('button', { name: translations.settings.danger.delete })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('dialog', {
|
||||
name: translations.settings.danger['delete-title']
|
||||
})
|
||||
).toBeVisible();
|
||||
|
||||
const verifyDeleteText = translations.settings.danger['verify-delete-text'];
|
||||
|
||||
const verifyDeleteInput = page.getByRole('textbox', {
|
||||
exact: true
|
||||
});
|
||||
await verifyDeleteInput.fill(verifyDeleteText);
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: translations.settings.danger.certain })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('dialog', {
|
||||
name: translations.settings.danger['delete-title']
|
||||
})
|
||||
).not.toBeVisible();
|
||||
|
||||
// TODO: Reinstate these checks when flakiness is resolved:
|
||||
// await expect(page).toHaveURL(allowTrailingSlash('/learn'));
|
||||
// await alertToBeVisible(page, translations.flash['account-deleted']);
|
||||
// The user is signed out after their account is deleted. Don't check the
|
||||
// number of occurrences of the 'Sign in' link as it may vary depending on AB
|
||||
// tests and Gatsby develop mode flakiness.
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'Sign in' }).first()
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Classic challenge - 3 pane desktop layout component', () => {
|
||||
test.skip(
|
||||
({ isMobile }) => isMobile === true,
|
||||
'Skip testing on mobile as this component is only used for desktop'
|
||||
);
|
||||
|
||||
test('The page has desktop layout with instructions/editor/preview pane', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(
|
||||
'learn/2022/responsive-web-design/build-a-survey-form-project/build-a-survey-form'
|
||||
);
|
||||
|
||||
const desktopLayout = page.getByTestId('desktop-layout');
|
||||
const actionRow = desktopLayout.getByTestId('action-row');
|
||||
const tabsRow = desktopLayout.getByTestId('tabs-row');
|
||||
const reflexContainer = desktopLayout.getByTestId('main-container');
|
||||
const instructionPane = desktopLayout.getByTestId('instruction-pane');
|
||||
const editorPane = desktopLayout.getByTestId('editor-pane');
|
||||
const previewPane = desktopLayout.getByTestId('preview-pane');
|
||||
|
||||
await expect(desktopLayout).toBeVisible();
|
||||
await expect(actionRow).toBeVisible();
|
||||
await expect(tabsRow).toBeVisible();
|
||||
await expect(reflexContainer).toBeVisible();
|
||||
await expect(instructionPane).toBeVisible();
|
||||
await expect(editorPane).toBeVisible();
|
||||
await expect(previewPane).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Classic challenge - 2 pane desktop layout component', () => {
|
||||
test.skip(
|
||||
({ isMobile }) => isMobile === true,
|
||||
'Skip testing on mobile as this component is only used for desktop'
|
||||
);
|
||||
|
||||
test('The page has desktop layout with instructions/editor pane', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(
|
||||
'learn/javascript-algorithms-and-data-structures/basic-javascript/use-recursion-to-create-a-range-of-numbers'
|
||||
);
|
||||
|
||||
const desktopLayout = page.getByTestId('desktop-layout');
|
||||
const actionRow = desktopLayout.getByTestId('action-row');
|
||||
const tabsRow = desktopLayout.getByTestId('tabs-row');
|
||||
const reflexContainer = desktopLayout.getByTestId('main-container');
|
||||
const instructionPane = desktopLayout.getByTestId('instruction-pane');
|
||||
const editorPane = desktopLayout.getByTestId('editor-pane');
|
||||
const previewPane = desktopLayout.getByTestId('preview-pane');
|
||||
|
||||
await expect(desktopLayout).toBeVisible();
|
||||
await expect(actionRow).toBeHidden();
|
||||
await expect(tabsRow).toBeHidden();
|
||||
await expect(reflexContainer).toBeVisible();
|
||||
await expect(instructionPane).toBeVisible();
|
||||
await expect(editorPane).toBeVisible();
|
||||
await expect(previewPane).toBeHidden();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,404 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import translations from '../client/i18n/locales/english/translations.json';
|
||||
import { addGrowthbookCookie } from './utils/add-growthbook-cookie';
|
||||
|
||||
const pageElements = {
|
||||
mainHeading: 'main-head',
|
||||
donateText1: 'donate-text-1',
|
||||
donateText2: 'donate-text-2',
|
||||
donateText3: 'donate-text-3',
|
||||
faqHeading: 'faq-head',
|
||||
campersImage: 'landing-page-figure'
|
||||
};
|
||||
|
||||
const donationStringReplacements = {
|
||||
usdPlaceHolder: '{{usd}}',
|
||||
hoursPlaceHolder: '{{hours}}'
|
||||
};
|
||||
|
||||
const donationFormStrings = {
|
||||
confirmTwentyDollar: translations.donate['confirm-monthly'].replace(
|
||||
donationStringReplacements.usdPlaceHolder,
|
||||
'20'
|
||||
),
|
||||
confirmTwentyFiveDollar: translations.donate['confirm-monthly'].replace(
|
||||
donationStringReplacements.usdPlaceHolder,
|
||||
'25'
|
||||
),
|
||||
confirmFiveDollars: translations.donate['confirm-monthly'].replace(
|
||||
donationStringReplacements.usdPlaceHolder,
|
||||
'5'
|
||||
),
|
||||
twentyDollarsLearningContribution: translations.donate['your-donation-2']
|
||||
.replace(donationStringReplacements.usdPlaceHolder, '20')
|
||||
.replace(donationStringReplacements.hoursPlaceHolder, '1,000'),
|
||||
twentyFiveDollarsLearningContribution: translations.donate['your-donation-2']
|
||||
.replace(donationStringReplacements.usdPlaceHolder, '25')
|
||||
.replace(donationStringReplacements.hoursPlaceHolder, '1,250'),
|
||||
fiveDollarsLearningContribution: translations.donate['your-donation-2']
|
||||
.replace(donationStringReplacements.usdPlaceHolder, '5')
|
||||
.replace(donationStringReplacements.hoursPlaceHolder, '250'),
|
||||
editAmount: translations.donate['edit-amount'],
|
||||
donate: translations.buttons.donate
|
||||
};
|
||||
|
||||
function donatePageTests() {
|
||||
test('should render correctly', async ({ page }) => {
|
||||
await expect(page).toHaveTitle(
|
||||
`${translations.donate.title} | freeCodeCamp.org`
|
||||
);
|
||||
|
||||
const mainHeading = page.getByTestId(pageElements.mainHeading);
|
||||
await expect(mainHeading).toHaveText(translations.donate['help-more']);
|
||||
|
||||
const donateText1 = page.getByTestId(pageElements.donateText1);
|
||||
await expect(donateText1).toHaveText(translations.donate.efficiency);
|
||||
|
||||
const donateText2 = page.getByTestId(pageElements.donateText2);
|
||||
await expect(donateText2).toHaveText(translations.donate['why-donate-1']);
|
||||
|
||||
const donateText3 = page.getByTestId(pageElements.donateText3);
|
||||
await expect(donateText3).toHaveText(translations.donate['why-donate-2']);
|
||||
|
||||
const faqHead = page.getByTestId(pageElements.faqHeading);
|
||||
await expect(faqHead).toHaveText(translations.donate.faq);
|
||||
});
|
||||
|
||||
test('should display the donation policy disclaimer', async ({ page }) => {
|
||||
const disclaimer = page.getByTestId('donation-policy-disclaimer');
|
||||
await expect(disclaimer).toHaveText(
|
||||
translations.donate['donation-policy-disclaimer']
|
||||
);
|
||||
});
|
||||
|
||||
test('should display the faq list with buttons', async ({ page }) => {
|
||||
const faq1 = page.getByRole('button', {
|
||||
name: translations.donate['get-help']
|
||||
});
|
||||
await expect(faq1).toBeVisible();
|
||||
await faq1.click();
|
||||
await expect(
|
||||
page.getByText(translations.donate['forward-receipt'])
|
||||
).toBeVisible();
|
||||
await faq1.click();
|
||||
|
||||
const faq2 = page.getByRole('button', {
|
||||
name: translations.donate['offer-refunds']
|
||||
});
|
||||
await expect(faq2).toBeVisible();
|
||||
await faq2.click();
|
||||
await expect(
|
||||
page.getByText(translations.donate['donations-are-voluntary'])
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(translations.donate['cancel-future-donations'])
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(translations.donate['without-your-authorization'])
|
||||
).toBeVisible();
|
||||
await faq2.click();
|
||||
|
||||
const faq3 = page.getByRole('button', {
|
||||
name: translations.donate['how-update']
|
||||
});
|
||||
await expect(faq3).toBeVisible();
|
||||
await faq3.click();
|
||||
await expect(
|
||||
page.getByText(translations.donate['take-care-of-this'])
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(translations.donate['help-update-change-cancel'])
|
||||
).toBeVisible();
|
||||
await faq3.click();
|
||||
|
||||
const faq4 = page.getByRole('button', {
|
||||
name: translations.donate['how-will-donation-appear']
|
||||
});
|
||||
await expect(faq4).toBeVisible();
|
||||
await faq4.click();
|
||||
await expect(
|
||||
page.getByText(translations.donate['as-freecodecamp-inc'])
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(translations.donate['do-not-recognize'])
|
||||
).toBeVisible();
|
||||
await faq4.click();
|
||||
|
||||
const faq5 = page.getByRole('button', {
|
||||
name: translations.donate['are-benefits-a-product']
|
||||
});
|
||||
await expect(faq5).toBeVisible();
|
||||
await faq5.click();
|
||||
await expect(
|
||||
page.getByText(translations.donate['benefits-are-thanks'])
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(translations.donate['benefits-not-sold-separately'])
|
||||
).toBeVisible();
|
||||
await faq5.click();
|
||||
|
||||
const faq6 = page.getByRole('button', {
|
||||
name: translations.donate['is-donation-tax-deductible']
|
||||
});
|
||||
await expect(faq6).toBeVisible();
|
||||
await faq6.click();
|
||||
await expect(
|
||||
page.getByText(
|
||||
translations.donate['freecodecamp-is-a-charitable-organization']
|
||||
)
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(translations.donate['donations-may-be-deductible'])
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(translations.donate['annual-donation-receipt'])
|
||||
).toBeVisible();
|
||||
await faq6.click();
|
||||
|
||||
const faq7 = page.getByRole('button', {
|
||||
name: translations.donate['how-transparent']
|
||||
});
|
||||
await expect(faq7).toBeVisible();
|
||||
await faq7.click();
|
||||
await expect(
|
||||
page.getByText(translations.donate['very-transparent'])
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('You can download our IRS Determination Letter here.')
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(
|
||||
'You can download our most recent 990 (annual tax report) here.'
|
||||
)
|
||||
).toBeVisible();
|
||||
await faq7.click();
|
||||
|
||||
const faq8 = page.getByRole('button', {
|
||||
name: translations.donate['how-efficient']
|
||||
});
|
||||
await expect(faq8).toBeVisible();
|
||||
await faq8.click();
|
||||
await expect(
|
||||
page.getByText(translations.donate['fcc-budget'])
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(translations.donate['help-millions'])
|
||||
).toBeVisible();
|
||||
await faq8.click();
|
||||
|
||||
const faq9 = page.getByRole('button', {
|
||||
name: translations.donate['how-one-time']
|
||||
});
|
||||
await expect(faq9).toBeVisible();
|
||||
await faq9.click();
|
||||
await expect(
|
||||
page.getByText(
|
||||
"If you prefer to make one-time donations, you can support freeCodeCamp's mission whenever you have cash to spare. You can use this link to donate any amount through PayPal."
|
||||
)
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(translations.donate['wire-transfer'])
|
||||
).toBeVisible();
|
||||
await faq9.click();
|
||||
|
||||
const faq10 = page.getByRole('button', {
|
||||
name: translations.donate['does-crypto']
|
||||
});
|
||||
await expect(faq10).toBeVisible();
|
||||
await faq10.click();
|
||||
await expect(
|
||||
page.getByText(translations.donate['yes-cryptocurrency'])
|
||||
).toBeVisible();
|
||||
await faq10.click();
|
||||
|
||||
const faq11 = page.getByRole('button', {
|
||||
name: translations.donate['can-check']
|
||||
});
|
||||
await expect(faq11).toBeVisible();
|
||||
await faq11.click();
|
||||
await expect(
|
||||
page.getByText(translations.donate['yes-check'])
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('Free Code Camp, Inc.')).toBeVisible();
|
||||
await expect(page.getByText('3905 Hedgcoxe Rd')).toBeVisible();
|
||||
await expect(page.getByText('PO Box 250352')).toBeVisible();
|
||||
await expect(page.getByText('Plano, TX 75025')).toBeVisible();
|
||||
await faq11.click();
|
||||
|
||||
const faq12 = page.getByRole('button', {
|
||||
name: translations.donate['how-matching-gift']
|
||||
});
|
||||
await expect(faq12).toBeVisible();
|
||||
await faq12.click();
|
||||
await expect(
|
||||
page.getByText(translations.donate['employers-vary'])
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(translations.donate['some-volunteer'])
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(translations.donate['help-matching-gift'])
|
||||
).toBeVisible();
|
||||
await faq12.click();
|
||||
|
||||
const faq13 = page.getByRole('button', {
|
||||
name: translations.donate['how-endowment']
|
||||
});
|
||||
await expect(faq13).toBeVisible();
|
||||
await faq13.click();
|
||||
await expect(
|
||||
page.getByText(translations.donate['endowment'])
|
||||
).toBeVisible();
|
||||
await faq13.click();
|
||||
|
||||
const faq14 = page.getByRole('button', {
|
||||
name: translations.donate['how-legacy']
|
||||
});
|
||||
await expect(faq14).toBeVisible();
|
||||
await faq14.click();
|
||||
await expect(
|
||||
page.getByText(translations.donate['we-honored'])
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(translations.donate['legacy-gift-message'])
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(translations.donate['thank-wikimedia'])
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(translations.donate['legacy-gift-questions'])
|
||||
).toBeVisible();
|
||||
await faq14.click();
|
||||
|
||||
const faq15 = page.getByRole('button', {
|
||||
name: translations.donate['how-stock']
|
||||
});
|
||||
await expect(faq15).toBeVisible();
|
||||
await faq15.click();
|
||||
await expect(
|
||||
page.getByText(translations.donate['welcome-stock'])
|
||||
).toBeVisible();
|
||||
await faq15.click();
|
||||
|
||||
const faq16 = page.getByRole('button', {
|
||||
name: translations.donate['anything-else']
|
||||
});
|
||||
await expect(faq16).toBeVisible();
|
||||
await faq16.click();
|
||||
await expect(
|
||||
page.getByText(translations.donate['other-support'])
|
||||
).toBeVisible();
|
||||
await faq16.click();
|
||||
});
|
||||
|
||||
test('should make $5 tier selectable', async ({ page }) => {
|
||||
await page.click('[role="tab"]:has-text("$5")');
|
||||
|
||||
await expect(
|
||||
page.getByText(donationFormStrings.confirmFiveDollars)
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText(donationFormStrings.fiveDollarsLearningContribution)
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should switch between tier selection and payment options', async ({
|
||||
page
|
||||
}) => {
|
||||
// Tier selection
|
||||
await page.click('[role="tab"]:has-text("$5")');
|
||||
await expect(
|
||||
page.getByText(donationFormStrings.confirmFiveDollars)
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(donationFormStrings.fiveDollarsLearningContribution)
|
||||
).toBeVisible();
|
||||
await page.click(`button:has-text("${donationFormStrings.donate}")`);
|
||||
|
||||
// Donation form
|
||||
const isEditButtonVisible = await page.isVisible(
|
||||
`button:has-text("${donationFormStrings.editAmount}")`
|
||||
);
|
||||
expect(isEditButtonVisible).toBeTruthy();
|
||||
await expect(page.getByTestId('donation-form')).toBeVisible();
|
||||
await page.click(`button:has-text("${donationFormStrings.editAmount}")`);
|
||||
|
||||
// Tier selection
|
||||
await expect(
|
||||
page.getByText(donationFormStrings.confirmFiveDollars)
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(donationFormStrings.fiveDollarsLearningContribution)
|
||||
).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
interface DefaultTierTestConfig {
|
||||
defaultTier: string;
|
||||
tiers: string[];
|
||||
confirmationText: string;
|
||||
contributionText: string;
|
||||
}
|
||||
|
||||
function donatePageDefault(config: DefaultTierTestConfig) {
|
||||
const { defaultTier, tiers, confirmationText, contributionText } = config;
|
||||
test(`should select ${defaultTier} tier by default`, async ({ page }) => {
|
||||
await expect(page.getByText(confirmationText)).toBeVisible();
|
||||
const tabs = await page.$$('[role="tab"]');
|
||||
expect(tabs.length).toBe(tiers.length);
|
||||
for (const tab of tabs) {
|
||||
const tabText = await tab.innerText();
|
||||
expect(tiers).toContain(tabText);
|
||||
const isActive = await tab.getAttribute('data-state');
|
||||
if (tabText === defaultTier) {
|
||||
expect(isActive).toBe('active');
|
||||
} else {
|
||||
expect(isActive).not.toBe('active');
|
||||
}
|
||||
}
|
||||
await expect(page.getByText(contributionText)).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('Authenticated User', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/donate');
|
||||
});
|
||||
donatePageTests();
|
||||
});
|
||||
|
||||
test.describe('Authenticated User Page Defaults - Variation A', () => {
|
||||
test.beforeEach(async ({ context, page }) => {
|
||||
await addGrowthbookCookie({ context, variation: 'A243' });
|
||||
await page.goto('/donate');
|
||||
});
|
||||
donatePageDefault({
|
||||
defaultTier: '$20',
|
||||
tiers: ['$5', '$10', '$20', '$40'],
|
||||
confirmationText: donationFormStrings.confirmTwentyDollar,
|
||||
contributionText: donationFormStrings.twentyDollarsLearningContribution
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Unauthenticated User', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/donate');
|
||||
});
|
||||
donatePageTests();
|
||||
});
|
||||
|
||||
test.describe('Unauthenticated User Page Default - Variation B', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
test.beforeEach(async ({ context, page }) => {
|
||||
await addGrowthbookCookie({ context, variation: 'B145' });
|
||||
await page.goto('/donate');
|
||||
});
|
||||
donatePageDefault({
|
||||
defaultTier: '$25',
|
||||
tiers: ['$5', '$10', '$25', '$40'],
|
||||
confirmationText: donationFormStrings.confirmTwentyFiveDollar,
|
||||
contributionText: donationFormStrings.twentyFiveDollarsLearningContribution
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { execSync } from 'child_process';
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Donate page', () => {
|
||||
test.use({ storageState: 'playwright/.auth/development-user.json' });
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
execSync('node ../tools/scripts/seed/seed-demo-user --set-true isDonating');
|
||||
await page.goto('/donate');
|
||||
});
|
||||
|
||||
test.afterAll(() => {
|
||||
execSync('node ../tools/scripts/seed/seed-demo-user --certified-user');
|
||||
});
|
||||
|
||||
test('should render the donate page correctly', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByText('Thank you for your continued support')
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText(
|
||||
'Your contributions are crucial in creating resources that empower millions of people to learn new skills and support their families.'
|
||||
)
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText(
|
||||
'If you want to support our charity further, please consider making a one-time donation, sending us a check, or learning about other ways you could support our charity.'
|
||||
)
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'making a one-time donation' })
|
||||
).toHaveAttribute(
|
||||
'href',
|
||||
'https://www.freecodecamp.org/news/how-to-donate-to-free-code-camp/#how-can-i-make-a-one-time-donation'
|
||||
);
|
||||
});
|
||||
|
||||
test('The menu should have a supporters link', async ({ page }) => {
|
||||
const menuButton = page.getByTestId('header-menu-button');
|
||||
const menu = page.getByTestId('header-menu');
|
||||
|
||||
await expect(menuButton).toBeVisible();
|
||||
await menuButton.click();
|
||||
|
||||
await expect(menu).toBeVisible();
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Supporters' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('The Avatar should have a special border for donors', async ({
|
||||
page
|
||||
}) => {
|
||||
const container = page.locator('.avatar-container');
|
||||
await expect(container).toHaveClass('avatar-container gold-border');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,469 @@
|
||||
import { execSync } from 'child_process';
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import { addGrowthbookCookie } from './utils/add-growthbook-cookie';
|
||||
|
||||
import { clearEditor, focusEditor } from './utils/editor';
|
||||
import { allowTrailingSlash } from './utils/url';
|
||||
|
||||
const slowExpect = expect.configure({ timeout: 25000 });
|
||||
|
||||
const completeFrontEndCert = async (page: Page, number?: number) => {
|
||||
await page.goto(
|
||||
`/learn/front-end-development-libraries/front-end-development-libraries-projects/build-a-random-quote-machine`
|
||||
);
|
||||
|
||||
const projects = [
|
||||
'random-quote-machine',
|
||||
'markdown-previewer',
|
||||
'drum-machine',
|
||||
'javascript-calculator',
|
||||
'25--5-clock'
|
||||
];
|
||||
|
||||
const loopNumber = number || projects.length;
|
||||
for (let i = 0; i < loopNumber; i++) {
|
||||
await page.waitForURL(
|
||||
allowTrailingSlash(
|
||||
`/learn/front-end-development-libraries/front-end-development-libraries-projects/build-a-${projects[i]}`
|
||||
)
|
||||
);
|
||||
await page
|
||||
.getByRole('textbox', { name: 'solution' })
|
||||
.fill('https://codepen.io/camperbot/full/oNvPqqo');
|
||||
await page
|
||||
.getByRole('button', { name: "I've completed this challenge" })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('button', { name: 'Submit and go to next challenge' })
|
||||
.click();
|
||||
}
|
||||
};
|
||||
|
||||
const challenges = [
|
||||
{
|
||||
url: '/learn/javascript-algorithms-and-data-structures/basic-javascript/comment-your-javascript-code',
|
||||
solution: `// some comment\n/* some comment */`
|
||||
},
|
||||
{
|
||||
url: '/learn/javascript-algorithms-and-data-structures/basic-javascript/declare-javascript-variables',
|
||||
solution: 'var myName;'
|
||||
},
|
||||
{
|
||||
url: '/learn/javascript-algorithms-and-data-structures/basic-javascript/storing-values-with-the-assignment-operator',
|
||||
solution: `// Setup\nvar a;\n\n// Only change code below this line\na = 7;`
|
||||
},
|
||||
{
|
||||
url: '/learn/javascript-algorithms-and-data-structures/basic-javascript/assigning-the-value-of-one-variable-to-another',
|
||||
solution: `// Setup\nvar a;\na = 7;\nvar b;\n\n// Only change code below this line\nb = a;`
|
||||
},
|
||||
{
|
||||
url: '/learn/javascript-algorithms-and-data-structures/basic-javascript/initializing-variables-with-the-assignment-operator',
|
||||
solution: 'var a = 9;'
|
||||
},
|
||||
{
|
||||
url: '/learn/javascript-algorithms-and-data-structures/basic-javascript/declare-string-variables',
|
||||
solution: `var myFirstName = 'foo';\nvar myLastName = 'bar';`
|
||||
},
|
||||
{
|
||||
url: '/learn/javascript-algorithms-and-data-structures/basic-javascript/understanding-uninitialized-variables',
|
||||
solution: `// Only change code below this line\nvar a = 5;\nvar b = 10;\nvar c = 'I am a';\n// Only change code above this line\n\na = a + 1;\nb = b + 5;\nc = c + " String!";`
|
||||
},
|
||||
{
|
||||
url: '/learn/javascript-algorithms-and-data-structures/basic-javascript/understanding-case-sensitivity-in-variables',
|
||||
solution: `// Variable declarations\nvar studlyCapVar;\nvar properCamelCase;\nvar titleCaseOver;\n\n// Variable assignments\nstudlyCapVar = 10;\nproperCamelCase = "A String";\ntitleCaseOver = 9000;`
|
||||
},
|
||||
{
|
||||
url: '/learn/javascript-algorithms-and-data-structures/basic-javascript/explore-differences-between-the-var-and-let-keywords',
|
||||
solution: `let catName = "Oliver";\nlet catSound = "Meow!";`
|
||||
},
|
||||
{
|
||||
url: '/learn/javascript-algorithms-and-data-structures/basic-javascript/declare-a-read-only-variable-with-the-const-keyword',
|
||||
solution: `const FCC = "freeCodeCamp";\n// Change this line\nlet fact = "is cool!";\n// Change this line\nfact = "is awesome!";\nconsole.log(FCC, fact);\n// Change this line`
|
||||
}
|
||||
];
|
||||
|
||||
const completeChallenges = async ({
|
||||
page,
|
||||
browserName,
|
||||
isMobile,
|
||||
number
|
||||
}: {
|
||||
page: Page;
|
||||
browserName: string;
|
||||
isMobile: boolean;
|
||||
number: number;
|
||||
}) => {
|
||||
await page.goto(challenges[0].url);
|
||||
for (const challenge of challenges.slice(0, number)) {
|
||||
await page.waitForURL(allowTrailingSlash(challenge.url));
|
||||
await focusEditor({ page, isMobile });
|
||||
await clearEditor({ page, browserName });
|
||||
await page.evaluate(
|
||||
async contents => await navigator.clipboard.writeText(contents),
|
||||
challenge.solution
|
||||
);
|
||||
await page.keyboard.press('ControlOrMeta+V');
|
||||
await page.getByRole('button', { name: 'Check Your Code' }).click();
|
||||
await page.getByRole('button', { name: 'Submit and continue' }).click();
|
||||
}
|
||||
};
|
||||
|
||||
test.skip(
|
||||
({ browserName }) => browserName !== 'chromium',
|
||||
'Only chromium allows us to use the clipboard API.'
|
||||
);
|
||||
|
||||
test.describe('Donation modal display', () => {
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await addGrowthbookCookie({ context, variation: 'A' });
|
||||
});
|
||||
|
||||
test('should display the content correctly and disable close when the animation is not complete', async ({
|
||||
page,
|
||||
browserName,
|
||||
isMobile,
|
||||
context
|
||||
}) => {
|
||||
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
test.setTimeout(40000);
|
||||
|
||||
await completeChallenges({ page, browserName, isMobile, number: 3 });
|
||||
|
||||
const donationModal = page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: 'Become a Supporter' });
|
||||
await expect(donationModal).toBeVisible();
|
||||
|
||||
await expect(
|
||||
donationModal.getByText(
|
||||
'This is a 20 second animated advertisement to encourage campers to become supporters of freeCodeCamp. The animation starts with a teddy bear who becomes a supporter. As a result, distracting pop-ups disappear and the bear gets to complete all of its goals. Then, it graduates and becomes an education super hero helping people around the world.'
|
||||
)
|
||||
).toBeVisible();
|
||||
await expect(donationModal.getByTestId('donation-animation')).toBeVisible();
|
||||
await expect(donationModal.getByText('Become a Supporter')).toBeVisible();
|
||||
await expect(donationModal.getByText('Remove distractions')).toBeVisible();
|
||||
await expect(
|
||||
donationModal.getByText('Reach your goals faster')
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
donationModal.getByText('Help millions of people learn')
|
||||
).toBeVisible();
|
||||
|
||||
// Ensure that the modal cannot be closed by pressing the Escape key
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(donationModal).toBeVisible();
|
||||
|
||||
// Second part of the modal.
|
||||
// Use `slowExpect` as we need to wait 20s for this part to show up.
|
||||
await slowExpect(
|
||||
donationModal.getByRole('img', {
|
||||
name: 'Illustration of an adorable teddy bear wearing a graduation cap and flying with a Supporter badge.'
|
||||
})
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
donationModal.getByRole('heading', {
|
||||
name: 'Support us'
|
||||
})
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
donationModal
|
||||
.getByRole('listitem')
|
||||
.filter({ hasText: 'Help us build more certifications' })
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
donationModal
|
||||
.getByRole('listitem')
|
||||
.filter({ hasText: 'Remove donation popups' })
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
donationModal
|
||||
.getByRole('listitem')
|
||||
.filter({ hasText: 'Help millions of people learn' })
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
donationModal.getByRole('button', { name: 'Become a Supporter' })
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
donationModal.getByRole('button', { name: 'Ask me later' })
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Donation modal appearance logic - New user', () => {
|
||||
test.use({ storageState: 'playwright/.auth/development-user.json' });
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await addGrowthbookCookie({ context, variation: 'B' });
|
||||
});
|
||||
|
||||
test.beforeEach(() => {
|
||||
execSync('node ../tools/scripts/seed/seed-demo-user');
|
||||
});
|
||||
|
||||
test.afterAll(() => {
|
||||
execSync('node ../tools/scripts/seed/seed-demo-user --certified-user');
|
||||
});
|
||||
|
||||
test('should not appear if the user has less than 10 completed challenges in total and has just completed 3 challenges', async ({
|
||||
page,
|
||||
browserName,
|
||||
isMobile,
|
||||
context
|
||||
}) => {
|
||||
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
|
||||
// Development user doesn't have any completed challenges, we are completing the first 3.
|
||||
await completeChallenges({ page, browserName, isMobile, number: 3 });
|
||||
|
||||
const donationModal = page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: 'Become a Supporter' });
|
||||
await expect(donationModal).toBeHidden();
|
||||
});
|
||||
|
||||
test('should appear if the user has less than 10 completed challenges in total and has just completed 10 challenges', async ({
|
||||
page,
|
||||
isMobile,
|
||||
browserName,
|
||||
context
|
||||
}) => {
|
||||
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
test.setTimeout(50000);
|
||||
|
||||
await completeChallenges({ page, isMobile, browserName, number: 10 });
|
||||
|
||||
const donationModal = page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: 'Become a Supporter' });
|
||||
await expect(donationModal).toBeVisible();
|
||||
await expect(
|
||||
donationModal.getByText(
|
||||
'This is a 20 second animated advertisement to encourage campers to become supporters of freeCodeCamp. The animation starts with a teddy bear who becomes a supporter. As a result, distracting pop-ups disappear and the bear gets to complete all of its goals. Then, it graduates and becomes an education super hero helping people around the world.'
|
||||
)
|
||||
).toBeVisible();
|
||||
|
||||
// Second part of the modal.
|
||||
// Use `slowExpect` as we need to wait 20s for this part to show up.
|
||||
await slowExpect(
|
||||
donationModal.getByRole('heading', { name: 'Support us' })
|
||||
).toBeVisible();
|
||||
await donationModal.getByRole('button', { name: 'Ask me later' }).click();
|
||||
// Ensure that the close state has been registered before ending the test.
|
||||
// The modal will show up on another page/test otherwise.
|
||||
await expect(donationModal).toBeHidden();
|
||||
});
|
||||
|
||||
test('should not appear if the user has just completed a new block but has less than 10 completed challenges', async ({
|
||||
page
|
||||
}) => {
|
||||
test.setTimeout(40000);
|
||||
|
||||
await completeFrontEndCert(page);
|
||||
|
||||
const donationModal = page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: 'Become a Supporter' });
|
||||
await expect(donationModal).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Donation modal appearance logic - Certified user claiming a new block', () => {
|
||||
test.use({ storageState: 'playwright/.auth/certified-user.json' });
|
||||
test.beforeEach(() =>
|
||||
execSync(
|
||||
'node ../tools/scripts/seed/seed-demo-user --almost-certified-user'
|
||||
)
|
||||
);
|
||||
|
||||
test('should appear if the user has just completed a new block, and should not appear if the user re-submits the projects of the block', async ({
|
||||
page,
|
||||
context
|
||||
}) => {
|
||||
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
test.setTimeout(40000);
|
||||
|
||||
await completeFrontEndCert(page, 1);
|
||||
|
||||
const donationModal = page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: 'Become a Supporter' });
|
||||
await expect(donationModal).toBeVisible();
|
||||
await expect(
|
||||
donationModal.getByText(
|
||||
'This is a 20 second animated advertisement to encourage campers to become supporters of freeCodeCamp. The animation starts with a teddy bear who becomes a supporter. As a result, distracting pop-ups disappear and the bear gets to complete all of its goals. Then, it graduates and becomes an education super hero helping people around the world.'
|
||||
)
|
||||
).toBeVisible();
|
||||
|
||||
// Second part of the modal.
|
||||
// Use `slowExpect` as we need to wait 20s for this part to show up.
|
||||
await slowExpect(
|
||||
donationModal.getByText(
|
||||
'Nicely done. You just completed Front-End Development Libraries Projects.'
|
||||
)
|
||||
).toBeVisible();
|
||||
await donationModal.getByRole('button', { name: 'Ask me later' }).click();
|
||||
await expect(donationModal).toBeHidden();
|
||||
|
||||
await completeFrontEndCert(page, 1);
|
||||
await expect(donationModal).toBeHidden();
|
||||
});
|
||||
|
||||
test("should not appear if the user has completed a new FSD block, but the block's module is not completed", async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(
|
||||
'/learn/responsive-web-design-v9/review-basic-html/basic-html-review'
|
||||
);
|
||||
|
||||
await page.getByRole('checkbox', { name: /Review/ }).click();
|
||||
await page.getByRole('button', { name: 'Submit', exact: true }).click();
|
||||
await page.getByRole('button', { name: /Submit and go/ }).click();
|
||||
|
||||
const donationModal = page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: 'Become a Supporter' });
|
||||
await expect(donationModal).toBeHidden();
|
||||
});
|
||||
|
||||
test('should not appear if FSD review module is completed', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/learn/responsive-web-design-v9/review-html/review-html');
|
||||
await page.getByRole('checkbox', { name: /Review/ }).click();
|
||||
await page.getByRole('button', { name: 'Submit', exact: true }).click();
|
||||
await page.getByRole('button', { name: /Submit and go/ }).click();
|
||||
await page.waitForTimeout(1000);
|
||||
const donationModal = page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: 'Become a Supporter' });
|
||||
await expect(donationModal).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Donation modal appearance logic - Certified user claiming a new module', () => {
|
||||
test.use({ storageState: 'playwright/.auth/certified-user.json' });
|
||||
test.beforeEach(() =>
|
||||
execSync(
|
||||
'node ../tools/scripts/seed/seed-demo-user --almost-certified-user'
|
||||
)
|
||||
);
|
||||
|
||||
test('should appear if the user has just completed a new module', async ({
|
||||
page
|
||||
}) => {
|
||||
test.setTimeout(40000);
|
||||
|
||||
// Go to the last lecture of the Code Editors block.
|
||||
// This lecture is not added to the seed data, so it is not completed.
|
||||
// By completing this lecture, we claim both the block and its module.
|
||||
await page.goto(
|
||||
'/learn/relational-databases-v9/lecture-working-with-code-editors-and-ides/what-are-some-good-vs-code-extensions-you-can-use-in-your-editor'
|
||||
);
|
||||
|
||||
// Wait for the page content to render
|
||||
// TODO: Change the selector to `getByRole('radiogroup')` when we have migrated the MCQ component to fcc/ui
|
||||
await expect(page.locator("div[class='video-quiz-options']")).toHaveCount(
|
||||
3
|
||||
);
|
||||
|
||||
const radioGroups = await page
|
||||
.locator("div[class='video-quiz-options']")
|
||||
.all();
|
||||
|
||||
await radioGroups[0].getByRole('radio').nth(1).click({ force: true });
|
||||
await radioGroups[1].getByRole('radio').nth(2).click({ force: true });
|
||||
await radioGroups[2].getByRole('radio').nth(1).click({ force: true });
|
||||
|
||||
await page.getByRole('button', { name: /Check your answer/ }).click();
|
||||
await page.getByRole('button', { name: /Submit and go/ }).click();
|
||||
|
||||
const donationModal = page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: 'Become a Supporter' });
|
||||
await expect(donationModal).toBeVisible();
|
||||
await expect(
|
||||
donationModal.getByText(
|
||||
'This is a 20 second animated advertisement to encourage campers to become supporters of freeCodeCamp. The animation starts with a teddy bear who becomes a supporter. As a result, distracting pop-ups disappear and the bear gets to complete all of its goals. Then, it graduates and becomes an education super hero helping people around the world.'
|
||||
)
|
||||
).toBeVisible();
|
||||
|
||||
// Second part of the modal.
|
||||
// Use `slowExpect` as we need to wait 20s for this part to show up.
|
||||
await slowExpect(
|
||||
donationModal.getByText('Nicely done. You just completed Code Editors.')
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Donation modal appearance logic - Certified user', () => {
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await addGrowthbookCookie({ context, variation: 'A' });
|
||||
});
|
||||
|
||||
test('should appear if the user has completed 3 challenges and has more than 10 completed challenges in total', async ({
|
||||
page,
|
||||
browserName,
|
||||
isMobile,
|
||||
context
|
||||
}) => {
|
||||
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
test.setTimeout(40000);
|
||||
|
||||
// Certified user already has more than 10 completed challenges, we are just completing 3 more.
|
||||
await completeChallenges({ page, isMobile, browserName, number: 3 });
|
||||
|
||||
const donationModal = page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: 'Become a Supporter' });
|
||||
await expect(donationModal).toBeVisible();
|
||||
|
||||
await expect(
|
||||
donationModal.getByText(
|
||||
'This is a 20 second animated advertisement to encourage campers to become supporters of freeCodeCamp. The animation starts with a teddy bear who becomes a supporter. As a result, distracting pop-ups disappear and the bear gets to complete all of its goals. Then, it graduates and becomes an education super hero helping people around the world.'
|
||||
)
|
||||
).toBeVisible();
|
||||
|
||||
// Second part of the modal.
|
||||
// Use `slowExpect` as we need to wait 20s for this part to show up.
|
||||
await slowExpect(
|
||||
donationModal.getByRole('heading', { name: 'Support us' })
|
||||
).toBeVisible();
|
||||
await donationModal.getByRole('button', { name: 'Ask me later' }).click();
|
||||
// Ensure that the close state has been registered before ending the test.
|
||||
// The modal will show up on another page/test otherwise.
|
||||
await expect(donationModal).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Donation modal appearance logic - Donor user', () => {
|
||||
test.beforeAll(() => {
|
||||
execSync(
|
||||
'node ../tools/scripts/seed/seed-demo-user --certified-user --set-true isDonating'
|
||||
);
|
||||
});
|
||||
|
||||
test.afterAll(() => {
|
||||
execSync('node ../tools/scripts/seed/seed-demo-user --certified-user');
|
||||
});
|
||||
|
||||
test('should not appear', async ({
|
||||
page,
|
||||
browserName,
|
||||
isMobile,
|
||||
context
|
||||
}) => {
|
||||
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
|
||||
await completeChallenges({ page, browserName, isMobile, number: 3 });
|
||||
|
||||
const donationModal = page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: 'Become a Supporter' });
|
||||
await expect(donationModal).toBeHidden();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import translations from '../client/i18n/locales/english/translations.json';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(
|
||||
'/learn/2022/responsive-web-design/build-a-survey-form-project/build-a-survey-form'
|
||||
);
|
||||
});
|
||||
|
||||
test('should toggle editor visibility correctly', async ({
|
||||
page,
|
||||
isMobile
|
||||
}) => {
|
||||
const htmlTabToggle = page.getByRole('button', { name: 'index.html Editor' });
|
||||
const cssTabToggle = page.getByRole('button', { name: 'styles.css Editor' });
|
||||
const htmlTab = page.getByTestId('editor-container-indexhtml');
|
||||
const cssTab = page.getByTestId('editor-container-stylescss');
|
||||
|
||||
if (isMobile) {
|
||||
const codeButton = page.getByRole('tab', {
|
||||
name: translations.learn['editor-tabs'].code
|
||||
});
|
||||
await codeButton.click();
|
||||
}
|
||||
await expect(htmlTabToggle).toBeVisible();
|
||||
// HTML tab is opened by default
|
||||
await expect(htmlTabToggle).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(htmlTab).toBeVisible();
|
||||
await htmlTabToggle.click();
|
||||
await expect(htmlTabToggle).toHaveAttribute('aria-expanded', 'false');
|
||||
await expect(htmlTab).not.toBeVisible();
|
||||
|
||||
await expect(cssTabToggle).toBeVisible();
|
||||
// CSS tab is not opened by default
|
||||
await expect(cssTabToggle).toHaveAttribute('aria-expanded', 'false');
|
||||
await expect(cssTab).not.toBeVisible();
|
||||
await cssTabToggle.click();
|
||||
await expect(cssTabToggle).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(cssTab).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
import { APIRequestContext, expect, test } from '@playwright/test';
|
||||
|
||||
import { clearEditor, focusEditor, getEditors } from './utils/editor';
|
||||
import { authedRequest } from './utils/request';
|
||||
|
||||
const setTheme = async (
|
||||
request: APIRequestContext,
|
||||
theme: 'default' | 'night'
|
||||
) =>
|
||||
authedRequest({
|
||||
request,
|
||||
endpoint: '/update-my-theme',
|
||||
method: 'put',
|
||||
data: {
|
||||
theme
|
||||
}
|
||||
});
|
||||
|
||||
const testPage =
|
||||
'/learn/responsive-web-design-v9/workshop-cat-photo-app/step-3';
|
||||
|
||||
test.describe('Editor Component', () => {
|
||||
test('should allow the user to insert text', async ({ page, isMobile }) => {
|
||||
await page.goto(testPage);
|
||||
|
||||
await focusEditor({ page, isMobile });
|
||||
await page.keyboard.insertText('<h2>FreeCodeCamp</h2>');
|
||||
const text = page.getByText('<h2>FreeCodeCamp</h2>');
|
||||
await expect(text).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Python Terminal', () => {
|
||||
test('should only have output if the python code generates it', async ({
|
||||
page,
|
||||
browserName,
|
||||
isMobile
|
||||
}) => {
|
||||
await page.goto('/learn/python-v9/workshop-caesar-cipher/step-2');
|
||||
|
||||
// First enter code that does not generate output
|
||||
await focusEditor({ page, isMobile });
|
||||
await clearEditor({ page, browserName, isMobile });
|
||||
await getEditors(page).fill('no = "output"');
|
||||
|
||||
if (isMobile) await page.getByRole('tab', { name: 'Preview' }).click();
|
||||
|
||||
const terminal = page.getByTestId('xterm-terminal');
|
||||
// Ensure there is no output
|
||||
await expect(async () => {
|
||||
const text = await terminal.innerText();
|
||||
expect(text.trim()).toBe('');
|
||||
}).toPass();
|
||||
|
||||
if (isMobile) await page.getByRole('tab', { name: 'Code' }).click();
|
||||
|
||||
// Now enter code that does generate output
|
||||
await focusEditor({ page, isMobile });
|
||||
await clearEditor({ page, browserName, isMobile });
|
||||
await getEditors(page).fill('some = "output"\nprint(some)');
|
||||
|
||||
if (isMobile) await page.getByRole('tab', { name: 'Preview' }).click();
|
||||
// Since the invisible characters are still there, we have to use "contain",
|
||||
// not "have"
|
||||
await expect(terminal).toContainText('output');
|
||||
});
|
||||
|
||||
test('should display error message when the user enters invalid code', async ({
|
||||
page,
|
||||
isMobile,
|
||||
browserName
|
||||
}) => {
|
||||
await page.goto('learn/python-v9/workshop-caesar-cipher/step-2');
|
||||
|
||||
await focusEditor({ page, isMobile });
|
||||
await clearEditor({ page, browserName, isMobile });
|
||||
// Then enter invalid code
|
||||
await getEditors(page).fill('def');
|
||||
|
||||
if (isMobile) {
|
||||
await page.getByRole('tab', { name: 'Preview' }).click();
|
||||
}
|
||||
|
||||
const terminal = page.getByTestId('xterm-terminal');
|
||||
|
||||
// While it's displayed on multiple lines, the string itself has no newlines, hence:
|
||||
const error = `Traceback (most recent call last): File "main.py", line 1 def ^SyntaxError: invalid syntax`;
|
||||
// It shouldn't take this long, but the Python worker can be slow to respond.
|
||||
await expect(terminal.getByText(error)).toContainText(error, {
|
||||
timeout: 15000
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Editor theme if the system theme is dark', () => {
|
||||
test.use({ colorScheme: 'dark' });
|
||||
|
||||
test.describe('If the user is signed in', () => {
|
||||
test('should respect the user settings', async ({ page, request }) => {
|
||||
const editor = page.locator("div[role='code'].monaco-editor");
|
||||
|
||||
await setTheme(request, 'night');
|
||||
await page.goto(testPage);
|
||||
|
||||
await expect(editor).toHaveClass(/vs-dark/);
|
||||
|
||||
await setTheme(request, 'default');
|
||||
await page.reload();
|
||||
|
||||
await expect(editor).toHaveClass(/vs(?!\w)/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('If the user is signed out and has no local storage data', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test('should be in dark mode', async ({ page }) => {
|
||||
await page.goto(testPage);
|
||||
const editor = page.locator("div[role='code'].monaco-editor");
|
||||
await expect(editor).toHaveClass(/vs-dark/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('if the user is signed out and has a dark theme set in local storage', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test('should be in dark mode', async ({ page }) => {
|
||||
// go to the test page
|
||||
await page.goto(testPage);
|
||||
|
||||
// set the dark theme in local storage
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('theme', 'dark');
|
||||
});
|
||||
|
||||
// reload the page to apply the local storage changes
|
||||
await page.reload();
|
||||
|
||||
// check if the editor is in dark mode
|
||||
const editor = page.locator("div[role='code'].monaco-editor");
|
||||
await expect(editor).toHaveClass(/vs-dark/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Editor theme if the system theme is light', () => {
|
||||
test.use({ colorScheme: 'light' });
|
||||
|
||||
test.describe('If the user is signed in', () => {
|
||||
test('should respect the user settings', async ({ page, request }) => {
|
||||
const editor = page.locator("div[role='code'].monaco-editor");
|
||||
|
||||
await setTheme(request, 'night');
|
||||
await page.goto(testPage);
|
||||
|
||||
await expect(editor).toHaveClass(/vs-dark/);
|
||||
|
||||
await setTheme(request, 'default');
|
||||
await page.reload();
|
||||
|
||||
await expect(editor).toHaveClass(/vs(?!\w)/);
|
||||
});
|
||||
|
||||
// This should be true for both system themes, but we're only testing light mode to save time.
|
||||
test('should switch the editor theme when the user toggles the theme', async ({
|
||||
page,
|
||||
request
|
||||
}) => {
|
||||
await setTheme(request, 'default');
|
||||
await page.goto(testPage);
|
||||
const editorContent = getEditors(page);
|
||||
// Wait for the editor's mount-time autofocus
|
||||
// so it cannot steal focus from the menu after we open it.
|
||||
await expect(editorContent).toBeFocused();
|
||||
|
||||
// Open the nav menu and toggle the theme
|
||||
const menuButton = page.getByRole('button', { name: 'Menu' });
|
||||
await menuButton.click();
|
||||
const menu = page.getByRole('list', { name: 'Menu' });
|
||||
await expect(menu).toBeVisible();
|
||||
|
||||
const toggle = menu.getByRole('button', { name: 'Night Mode' });
|
||||
await expect(toggle).toBeVisible();
|
||||
await toggle.click();
|
||||
|
||||
// Ensure that the action is completed before checking the editor.
|
||||
await expect(page.getByText('We have updated your theme')).toBeVisible();
|
||||
|
||||
const editor = page.locator("div[role='code'].monaco-editor");
|
||||
await expect(editor).toHaveClass(/vs-dark/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('If the user is signed out and has no local storage value', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test('should be in light mode', async ({ page }) => {
|
||||
await page.goto(testPage);
|
||||
const editor = page.locator("div[role='code'].monaco-editor");
|
||||
await expect(editor).toHaveClass(/vs(?!\w)/);
|
||||
});
|
||||
|
||||
test.describe('if the user is signed out and has a light theme set in local storage', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test('should be in light mode', async ({ page }) => {
|
||||
// go to the test page
|
||||
await page.goto(testPage);
|
||||
|
||||
// set the light theme in local storage
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('theme', 'light');
|
||||
});
|
||||
|
||||
// reload the page to apply the local storage changes
|
||||
await page.reload();
|
||||
|
||||
// check if the editor is in light mode
|
||||
const editor = page.locator("div[role='code'].monaco-editor");
|
||||
await expect(editor).toHaveClass(/vs(?!\w)/);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
import translations from '../client/i18n/locales/english/translations.json';
|
||||
|
||||
const settingsPageElement = {
|
||||
emailVerificationAlert: 'email-verification-alert',
|
||||
emailVerificationLink: 'email-verification-link',
|
||||
flashMessageAlert: 'flash-message',
|
||||
newEmailValidation: 'new-email-validation',
|
||||
confirmEmailValidation: 'confirm-email-validation'
|
||||
} as const;
|
||||
|
||||
const originalEmail = 'foo@bar.com';
|
||||
const newEmail = 'foo-update@bar.com';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
execSync('node ../tools/scripts/seed/seed-demo-user --certified-user');
|
||||
await page.goto('/settings');
|
||||
});
|
||||
|
||||
test.describe('Email Settings', () => {
|
||||
test('should display the content correctly', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { name: translations.settings.email.heading })
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page.getByText(originalEmail)).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: `${translations.buttons.save} ${translations.settings.email.heading}`
|
||||
})
|
||||
).toBeDisabled();
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByRole('group', { name: translations.settings.email.weekly })
|
||||
.locator('legend')
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: translations.buttons['yes-please']
|
||||
})
|
||||
).toHaveAttribute('aria-pressed', 'false');
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: translations.buttons['no-thanks']
|
||||
})
|
||||
).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
test('should display email verification alert after email update', async ({
|
||||
page
|
||||
}) => {
|
||||
const flashMessageAlert = page.getByTestId(
|
||||
settingsPageElement.flashMessageAlert
|
||||
);
|
||||
// Need exact match as there are "New email" and "Confirm new email" labels
|
||||
await page
|
||||
.getByLabel(translations.settings.email.new, { exact: true })
|
||||
.fill(newEmail);
|
||||
|
||||
await page.getByLabel(translations.settings.email.confirm).fill(newEmail);
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: `${translations.buttons.save} ${translations.settings.email.heading}`
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(flashMessageAlert).toBeVisible();
|
||||
await expect(flashMessageAlert).toContainText(
|
||||
'Check your email and click the link we sent you to confirm your new email address.'
|
||||
);
|
||||
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.getByTestId(settingsPageElement.emailVerificationAlert)
|
||||
).toBeVisible();
|
||||
|
||||
const emailVerificationLink = page.getByTestId(
|
||||
settingsPageElement.emailVerificationLink
|
||||
);
|
||||
await expect(emailVerificationLink).toHaveAttribute(
|
||||
'href',
|
||||
'/update-email'
|
||||
);
|
||||
});
|
||||
|
||||
test('should show the user error messages if the input is invalid', async ({
|
||||
page
|
||||
}) => {
|
||||
const newEmailInput = page.getByLabel(translations.settings.email.new, {
|
||||
exact: true
|
||||
});
|
||||
const confirmEmailInput = page.getByLabel(
|
||||
translations.settings.email.confirm
|
||||
);
|
||||
const confirmValidation = page.getByTestId(
|
||||
settingsPageElement.confirmEmailValidation
|
||||
);
|
||||
const newEmailValidation = page.getByTestId(
|
||||
settingsPageElement.newEmailValidation
|
||||
);
|
||||
|
||||
await newEmailInput.fill(newEmail);
|
||||
await confirmEmailInput.fill(originalEmail);
|
||||
|
||||
await expect(confirmValidation).toBeVisible();
|
||||
await expect(confirmValidation).toContainText(
|
||||
translations.validation['email-mismatch']
|
||||
);
|
||||
|
||||
await newEmailInput.fill(originalEmail);
|
||||
|
||||
await expect(newEmailValidation).toBeVisible();
|
||||
await expect(newEmailValidation).toContainText(
|
||||
translations.validation['same-email']
|
||||
);
|
||||
});
|
||||
|
||||
test('should toggle email subscription correctly', async ({ page }) => {
|
||||
const yesPleaseButton = page.getByRole('button', {
|
||||
name: translations.buttons['yes-please']
|
||||
});
|
||||
const noThanksButton = page.getByRole('button', {
|
||||
name: translations.buttons['no-thanks']
|
||||
});
|
||||
|
||||
await yesPleaseButton.click();
|
||||
await expect(yesPleaseButton).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(noThanksButton).toHaveAttribute('aria-pressed', 'false');
|
||||
|
||||
await noThanksButton.click();
|
||||
await expect(yesPleaseButton).toHaveAttribute('aria-pressed', 'false');
|
||||
await expect(noThanksButton).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
test('should display flash message when email subscription is toggled', async ({
|
||||
page
|
||||
}) => {
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: translations.buttons['yes-please']
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByTestId(settingsPageElement.flashMessageAlert)
|
||||
).toContainText("We have updated your subscription to Quincy's email");
|
||||
|
||||
// Undo subscription change
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
response =>
|
||||
response.url().includes('update-my-quincy-email') &&
|
||||
response.status() === 200
|
||||
),
|
||||
page
|
||||
.getByRole('button', {
|
||||
name: translations.buttons['no-thanks']
|
||||
})
|
||||
.click()
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { execSync } from 'child_process';
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
import translations from '../client/i18n/locales/english/translations.json';
|
||||
import { alertToBeVisible } from './utils/alerts';
|
||||
|
||||
test.describe("Email sign-up alert when user has not selected Quincy's newsletter preference", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// It's necessary to seed with a user that has not accepted the privacy
|
||||
// terms, otherwise the user will be redirected away from the email sign-up
|
||||
// page.
|
||||
execSync('node ../tools/scripts/seed/seed-demo-user --certified-user');
|
||||
await page.goto('/learn');
|
||||
});
|
||||
|
||||
test('should disable weekly newsletter if the user clicks No', async ({
|
||||
page
|
||||
}) => {
|
||||
await expect(
|
||||
page.getByText(translations.misc['email-blast'])
|
||||
).toBeVisible();
|
||||
|
||||
const noThanksButton = page.getByRole('button', {
|
||||
name: translations.buttons['no-thanks']
|
||||
});
|
||||
await expect(noThanksButton).toBeVisible();
|
||||
await noThanksButton.click();
|
||||
await alertToBeVisible(
|
||||
page,
|
||||
translations.flash['subscribe-to-quincy-updated']
|
||||
);
|
||||
await expect(
|
||||
page.getByText(translations.misc['email-blast'])
|
||||
).not.toBeVisible();
|
||||
await page.goto('/settings');
|
||||
await expect(
|
||||
page.getByRole('button', { name: translations.buttons['no-thanks'] })
|
||||
).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(
|
||||
page.getByRole('button', { name: translations.buttons['yes-please'] })
|
||||
).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
test('should enable weekly newsletter if the user clicks Yes', async ({
|
||||
page
|
||||
}) => {
|
||||
await expect(
|
||||
page.getByText(translations.misc['email-blast'])
|
||||
).toBeVisible();
|
||||
|
||||
const yesPleaseButton = page.getByRole('button', {
|
||||
name: translations.buttons['yes-please']
|
||||
});
|
||||
|
||||
await expect(yesPleaseButton).toBeVisible();
|
||||
await yesPleaseButton.click();
|
||||
await alertToBeVisible(
|
||||
page,
|
||||
translations.flash['subscribe-to-quincy-updated']
|
||||
);
|
||||
await page.goto('/settings');
|
||||
await expect(
|
||||
page.getByRole('group', { name: translations.settings.email.weekly })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: translations.buttons['yes-please'] })
|
||||
).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(
|
||||
page.getByRole('button', { name: translations.buttons['no-thanks'] })
|
||||
).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { configTypeChecked } from '@freecodecamp/eslint-config/base';
|
||||
import { defineConfig, globalIgnores } from 'eslint/config';
|
||||
|
||||
export default defineConfig(globalIgnores(['./playwright']), {
|
||||
extends: [configTypeChecked],
|
||||
rules: {
|
||||
'@typescript-eslint/no-unsafe-member-access': 'off',
|
||||
'@typescript-eslint/no-unsafe-call': 'off',
|
||||
'@typescript-eslint/no-unsafe-assignment': 'off',
|
||||
'turbo/no-undeclared-env-vars': 'off' // If/when we make e2e tests into a turbo task, we can enable this rule again.
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import * as fs from 'fs';
|
||||
import { test, expect } from '@playwright/test';
|
||||
import translations from '../client/i18n/locales/english/translations.json';
|
||||
import intro from '../client/i18n/locales/english/intro.json';
|
||||
|
||||
const examUrl =
|
||||
'/learn/foundational-c-sharp-with-microsoft/foundational-c-sharp-with-microsoft-certification-exam/foundational-c-sharp-with-microsoft-certification-exam';
|
||||
|
||||
test.describe('Exam Results E2E Test Suite', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(examUrl);
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: translations.buttons['click-start-exam']
|
||||
})
|
||||
.click();
|
||||
const QUESTION_COUNT = 5;
|
||||
for (let i = 0; i < QUESTION_COUNT; i++) {
|
||||
await page.getByRole('radio').first().check({ force: true });
|
||||
|
||||
if (i < QUESTION_COUNT - 1) {
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: translations.buttons['next-question']
|
||||
})
|
||||
.click();
|
||||
} else {
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: translations.buttons['finish-exam']
|
||||
})
|
||||
.click();
|
||||
await page
|
||||
.getByRole('button', { name: translations.learn.exam['finish-yes'] })
|
||||
.click();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Verifies the Correct Rendering of the Exam results', async ({
|
||||
page
|
||||
}) => {
|
||||
await expect(
|
||||
page
|
||||
.locator('div.exam-results-wrapper')
|
||||
.getByText(
|
||||
intro['foundational-c-sharp-with-microsoft'].blocks[
|
||||
'foundational-c-sharp-with-microsoft-certification-exam'
|
||||
].title
|
||||
)
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.locator('div.exam-results-wrapper')
|
||||
.getByText(translations.learn.exam['not-passed-message'])
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.locator('div.exam-results-wrapper')
|
||||
.getByTestId('exam-results-question-results')
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator('div.exam-results-wrapper').getByText(/\d% correct/)
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator('div.exam-results-wrapper').getByText(/Time: \d:\d/)
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.locator('div.exam-results-wrapper')
|
||||
.getByTestId('download-exam-results')
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.locator('div.exam-results-wrapper')
|
||||
.getByRole('button', { name: translations.buttons.exit })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('Exam Results when the User clicks on Exit button', async ({ page }) => {
|
||||
await page
|
||||
.locator('div.exam-results-wrapper')
|
||||
.getByRole('button', { name: translations.buttons.exit })
|
||||
.click();
|
||||
await expect(
|
||||
page
|
||||
.locator('div.exam-results-wrapper')
|
||||
.getByText(
|
||||
intro['foundational-c-sharp-with-microsoft'].blocks[
|
||||
'foundational-c-sharp-with-microsoft-certification-exam'
|
||||
].title
|
||||
)
|
||||
).not.toBeVisible();
|
||||
await expect(page).not.toHaveURL(examUrl);
|
||||
});
|
||||
|
||||
test.describe('Exam Results E2E Test Suite', () => {
|
||||
test('Exam Results When the User clicks on Download button', async ({
|
||||
page
|
||||
}) => {
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent('download'),
|
||||
page
|
||||
.locator('div.exam-results-wrapper')
|
||||
.getByTestId('download-exam-results')
|
||||
.click()
|
||||
]);
|
||||
const suggestedFileName = download.suggestedFilename();
|
||||
await download.saveAs(suggestedFileName);
|
||||
expect(fs.existsSync(suggestedFileName)).toBeTruthy();
|
||||
await download.delete();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import intro from '../client/i18n/locales/english/intro.json';
|
||||
import translations from '../client/i18n/locales/english/translations.json';
|
||||
|
||||
const examUrl =
|
||||
'/learn/foundational-c-sharp-with-microsoft/foundational-c-sharp-with-microsoft-certification-exam/foundational-c-sharp-with-microsoft-certification-exam';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(examUrl);
|
||||
});
|
||||
|
||||
test.describe('Exam Show E2E Test Suite for qualified user', () => {
|
||||
test('starts the exam without navigating away', async ({ page }) => {
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: translations.buttons['click-start-exam']
|
||||
})
|
||||
.click();
|
||||
await expect(page).toHaveURL(examUrl);
|
||||
await expect(page.getByTestId('exam-show-title')).toHaveText(
|
||||
intro['foundational-c-sharp-with-microsoft'].blocks[
|
||||
'foundational-c-sharp-with-microsoft-certification-exam'
|
||||
].title
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { execSync } from 'child_process';
|
||||
import { test, expect } from '@playwright/test';
|
||||
const url =
|
||||
'/learn/foundational-c-sharp-with-microsoft/foundational-c-sharp-with-microsoft-certification-exam/foundational-c-sharp-with-microsoft-certification-exam';
|
||||
test.describe('Exam Survey', () => {
|
||||
test.beforeAll(() => {
|
||||
execSync('node ../tools/scripts/seed/seed-demo-user --certified-user');
|
||||
execSync('node ../tools/scripts/seed/seed-surveys.js delete-only');
|
||||
});
|
||||
|
||||
test('Should show the survey alert and be able to complete the survey', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(url);
|
||||
|
||||
await expect(page.getByTestId('c-sharp-survey-alert')).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Take the survey' }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: 'Foundational C# with Microsoft Survey'
|
||||
})
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByText('Student developer').click();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Submit the survey' })
|
||||
).toBeDisabled();
|
||||
|
||||
await page.getByText('Novice (no prior experience)').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Submit the survey' }).click();
|
||||
|
||||
await expect(page.getByTestId('flash-message')).toContainText(
|
||||
/Thank you. Your survey was submitted./
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
// TO ENABLE THESE TESTS GROWTHBOOK HAS TO BE SET IN THE ENVIRONMENT VARIABLES`
|
||||
// UNCOMMENT WHEN NEW API IS READY.
|
||||
|
||||
// import { test, expect } from '@playwright/test';
|
||||
|
||||
// test.beforeEach(async ({ page }) => {
|
||||
// await page.goto('/settings');
|
||||
// });
|
||||
|
||||
// test.describe('Exam Token Widget', () => {
|
||||
// test('should tell you to not share the token with anyone', async ({
|
||||
// page
|
||||
// }) => {
|
||||
// await expect(
|
||||
// page.getByText(
|
||||
// 'Your exam token is a secret key that allows you to access the exam. Do not share this token with anyone'
|
||||
// )
|
||||
// ).toBeVisible();
|
||||
// await expect(
|
||||
// page.getByText(
|
||||
// 'If you generate a new token, your old token will be invalidated.'
|
||||
// )
|
||||
// ).toBeVisible();
|
||||
|
||||
// await expect(
|
||||
// page.getByRole('button', { name: 'Generate Exam Token' })
|
||||
// ).toBeVisible();
|
||||
// });
|
||||
|
||||
// test('should be able to generate a token', async ({ page }) => {
|
||||
// await page.getByRole('button', { name: 'Generate Exam Token' }).click();
|
||||
// await expect(page.getByText('Your Exam Token is:')).toBeVisible();
|
||||
|
||||
// await expect(page.getByRole('button', { name: 'Copy' })).toBeVisible();
|
||||
|
||||
// await expect(
|
||||
// page.getByRole('button', { name: 'Close' }).nth(1)
|
||||
// ).toBeVisible();
|
||||
// });
|
||||
// });
|
||||
@@ -0,0 +1,96 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import translations from '../client/i18n/locales/english/translations.json';
|
||||
|
||||
const examUrl =
|
||||
'/learn/foundational-c-sharp-with-microsoft/foundational-c-sharp-with-microsoft-certification-exam/foundational-c-sharp-with-microsoft-certification-exam';
|
||||
const cancelExamUrl =
|
||||
'/learn/foundational-c-sharp-with-microsoft/#foundational-c-sharp-with-microsoft-certification-exam';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(examUrl);
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: translations.buttons['click-start-exam']
|
||||
})
|
||||
.click();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: translations.buttons['exit-exam']
|
||||
})
|
||||
.click();
|
||||
});
|
||||
|
||||
test.describe('Exit exam Modal E2E Test Suite', () => {
|
||||
test('Verifies the Correct Rendering of the Exit exam Modal', async ({
|
||||
page
|
||||
}) => {
|
||||
await expect(
|
||||
page.getByRole('dialog', { name: translations.learn.exam['exit-header'] })
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page.getByText(translations.learn.exam.exit)).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: translations.learn.exam['exit-yes']
|
||||
})
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: translations.buttons.close
|
||||
})
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: translations.learn.exam['exit-no']
|
||||
})
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('Closes the Exit exam Modal When the User clicks on exit-no button', async ({
|
||||
page
|
||||
}) => {
|
||||
await page
|
||||
.getByRole('button', { name: translations.learn.exam['exit-no'] })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('dialog', { name: translations.learn.exam['exit-header'] })
|
||||
).not.toBeVisible();
|
||||
|
||||
await expect(page).toHaveURL(examUrl);
|
||||
});
|
||||
|
||||
test('Closes the Modal when the User clicks on exit-yes button', async ({
|
||||
page
|
||||
}) => {
|
||||
await page
|
||||
.getByRole('button', { name: translations.learn.exam['exit-yes'] })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('dialog', { name: translations.learn.exam['exit-header'] })
|
||||
).not.toBeVisible();
|
||||
|
||||
await expect(page).toHaveURL(cancelExamUrl);
|
||||
});
|
||||
|
||||
test('Closes the Modal when the User clicks on X button', async ({
|
||||
page
|
||||
}) => {
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: translations.buttons.close
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('dialog', { name: translations.learn.exam['exit-header'] })
|
||||
).not.toBeVisible();
|
||||
|
||||
await expect(page).toHaveURL(examUrl);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { execSync } from 'child_process';
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.use({ storageState: 'playwright/.auth/development-user.json' });
|
||||
|
||||
test.beforeAll(() => {
|
||||
execSync('node ../tools/scripts/seed/seed-demo-user');
|
||||
});
|
||||
|
||||
test.afterAll(() => {
|
||||
execSync('node ../tools/scripts/seed/seed-demo-user --certified-user');
|
||||
});
|
||||
|
||||
test.describe('Add Experience Item', () => {
|
||||
test.skip(({ browserName }) => browserName === 'webkit', 'flaky on Safari');
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/developmentuser');
|
||||
|
||||
// The 'Add experience' icon button is on the profile page directly.
|
||||
// Click it to open the experience modal.
|
||||
await page.getByRole('button', { name: 'Add experience' }).click();
|
||||
|
||||
// Wait for the experience form to be visible inside the modal.
|
||||
await expect(page.getByLabel('Company')).toBeVisible();
|
||||
});
|
||||
|
||||
test('The company has validation', async ({ page }) => {
|
||||
await page.getByLabel('Company').fill('A');
|
||||
await expect(page.getByText('Company name is too short')).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByLabel('Company')
|
||||
.fill(
|
||||
'This is the longest company name you will ever see in your entire life, you will never see such a long company name again. This is the longest company name in existen'
|
||||
);
|
||||
await expect(page.getByText('Company name is too long')).toBeVisible();
|
||||
await page.getByLabel('Company').fill('freeCodeCamp');
|
||||
await expect(page.getByText('Company name is too short')).toBeHidden();
|
||||
await expect(page.getByText('Company name is too long')).toBeHidden();
|
||||
});
|
||||
|
||||
test('The position has validation', async ({ page }) => {
|
||||
await page.getByLabel('Job Title').fill('A');
|
||||
await expect(page.getByText('Title is too short')).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByLabel('Job Title')
|
||||
.fill(
|
||||
'This is the longest position you will ever see in your entire life, you will never see such a long position again. This is the longest position in existen'
|
||||
);
|
||||
await expect(page.getByText('Title is too long')).toBeVisible();
|
||||
await page.getByLabel('Job Title').fill('Software Engineer');
|
||||
await expect(page.getByText('Title is too short')).toBeHidden();
|
||||
await expect(page.getByText('Title is too long')).toBeHidden();
|
||||
});
|
||||
|
||||
test('The start date has validation', async ({ page }) => {
|
||||
await page.getByLabel('Start Date').fill('13/2023');
|
||||
await expect(page.getByText('Please enter a valid date.')).toBeVisible();
|
||||
|
||||
await page.getByLabel('Start Date').fill('01/2023');
|
||||
await expect(page.getByText('Please enter a valid date.')).toBeHidden();
|
||||
});
|
||||
|
||||
test('The end date has validation', async ({ page }) => {
|
||||
await page.getByLabel('End Date', { exact: false }).fill('13/2023');
|
||||
await expect(page.getByText('Please enter a valid date.')).toBeVisible();
|
||||
|
||||
await page.getByLabel('End Date', { exact: false }).fill('01/2023');
|
||||
await expect(page.getByText('Please enter a valid date.')).toBeHidden();
|
||||
});
|
||||
|
||||
test('The description has validation', async ({ page }) => {
|
||||
await page.getByLabel('Description').fill('A'.repeat(1001));
|
||||
await expect(
|
||||
page.getByText(
|
||||
'There is a maximum limit of 500 characters, you have 0 left'
|
||||
)
|
||||
).toBeVisible();
|
||||
await page.getByLabel('Description').fill('Worked on various projects');
|
||||
await expect(
|
||||
page.getByText(
|
||||
'There is a maximum limit of 500 characters, you have 0 left'
|
||||
)
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
test('It should be possible to delete an experience item', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.getByLabel('Company').fill('freeCodeCamp');
|
||||
await page.getByLabel('Job Title').fill('Software Engineer');
|
||||
await page.getByLabel('Start Date').fill('01/2020');
|
||||
await page.getByLabel('End Date', { exact: false }).fill('01/2021');
|
||||
// Use locator to avoid conflict with About section's Location field
|
||||
await page.locator('input[name="experience-location"]').fill('Remote');
|
||||
await page.getByLabel('Description').fill('Worked on various projects');
|
||||
|
||||
await page.getByRole('button', { name: 'Remove experience' }).click();
|
||||
|
||||
// Modal closes automatically after removal
|
||||
await expect(page.getByRole('alert').first()).toContainText(
|
||||
/We have updated your experience/
|
||||
);
|
||||
});
|
||||
|
||||
test('It should be possible to add an experience item', async ({ page }) => {
|
||||
await page.getByLabel('Company').fill('freeCodeCamp');
|
||||
await page.getByLabel('Job Title').fill('Software Engineer');
|
||||
await page.getByLabel('Start Date').fill('01/2020');
|
||||
await page.getByLabel('End Date', { exact: false }).fill('01/2021');
|
||||
// Use locator to avoid conflict with About section's Location field
|
||||
await page.locator('input[name="experience-location"]').fill('Remote');
|
||||
await page.getByLabel('Description').fill('Worked on various projects');
|
||||
|
||||
await page.getByRole('button', { name: 'Save experience' }).click();
|
||||
|
||||
// Modal closes automatically after a successful save
|
||||
await expect(page.getByRole('alert').first()).toContainText(
|
||||
/We have updated your experience/
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { execSync } from 'child_process';
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const failedUpdates = [
|
||||
{
|
||||
endpoint: '/modern-challenge-completed',
|
||||
payload: { id: '5dc1798ff86c76b9248c6eb3', challengeType: 0 },
|
||||
id: '4bd1d704-cfaa-44f7-92a3-bc0d857dbaa6'
|
||||
},
|
||||
{
|
||||
endpoint: '/modern-challenge-completed',
|
||||
payload: { id: '5dc17d3bf86c76b9248c6eb4', challengeType: 0 },
|
||||
id: 'ea289e2f-a5d2-45e0-b795-0f9f4afc5124'
|
||||
}
|
||||
];
|
||||
const storeKey = 'fcc-failed-updates';
|
||||
|
||||
function getCompletedIds(completedChallenges: { id: string }[]): string[] {
|
||||
return completedChallenges.map(challenge => challenge.id);
|
||||
}
|
||||
test.use({ storageState: 'playwright/.auth/development-user.json' });
|
||||
|
||||
test.beforeAll(() => {
|
||||
execSync('node ../tools/scripts/seed/seed-demo-user');
|
||||
});
|
||||
|
||||
test.afterAll(() => {
|
||||
execSync('node ../tools/scripts/seed/seed-demo-user --certified-user');
|
||||
});
|
||||
|
||||
test.describe('failed update flushing', () => {
|
||||
test('should resubmit failed updates to the api and clear the store', async ({
|
||||
page,
|
||||
request
|
||||
}) => {
|
||||
// Initially, the user has no completed challenges.
|
||||
const userRes = await request.get(
|
||||
new URL('/user/session-user', process.env.API_LOCATION).toString()
|
||||
);
|
||||
const completedChallenges = (await userRes.json()).user.developmentuser
|
||||
.completedChallenges;
|
||||
expect(completedChallenges).toEqual([]);
|
||||
|
||||
// It's necessary to wait until the page has loaded before setting the
|
||||
// localStorage. Otherwise, evaluate fails with a permissions error (the
|
||||
// store doesn't exist yet).
|
||||
await page.goto('/');
|
||||
await page.evaluate(
|
||||
([failedUpdates, storeKey]) => {
|
||||
localStorage.setItem(storeKey, JSON.stringify(failedUpdates));
|
||||
},
|
||||
[failedUpdates, storeKey] as const
|
||||
);
|
||||
|
||||
// The update epic sends two requests and this lets us wait for both.
|
||||
const submitRes = page
|
||||
.waitForResponse(
|
||||
new URL(
|
||||
'/modern-challenge-completed',
|
||||
process.env.API_LOCATION
|
||||
).toString()
|
||||
)
|
||||
|
||||
.then(() =>
|
||||
page.waitForResponse(
|
||||
new URL(
|
||||
'/modern-challenge-completed',
|
||||
process.env.API_LOCATION
|
||||
).toString()
|
||||
)
|
||||
);
|
||||
|
||||
await page.reload();
|
||||
await submitRes;
|
||||
|
||||
const updatedUserRes = await request.get(
|
||||
new URL('/user/session-user', process.env.API_LOCATION).toString()
|
||||
);
|
||||
|
||||
// Now the user should have both completed challenges.
|
||||
const updatedCompletedChallenges = (await updatedUserRes.json()).user
|
||||
.developmentuser.completedChallenges;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
const completedIds = getCompletedIds(updatedCompletedChallenges);
|
||||
|
||||
for (const { payload } of failedUpdates) {
|
||||
expect(completedIds).toContain(payload.id);
|
||||
}
|
||||
const storedFailedUpdates = await page.evaluate(storeKey => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return JSON.parse(localStorage.getItem(storeKey) ?? '');
|
||||
}, storeKey);
|
||||
expect(storedFailedUpdates).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import translations from '../client/i18n/locales/english/translations.json';
|
||||
|
||||
const examUrl =
|
||||
'/learn/foundational-c-sharp-with-microsoft/foundational-c-sharp-with-microsoft-certification-exam/foundational-c-sharp-with-microsoft-certification-exam';
|
||||
const QUESTION_COUNT = 5;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(examUrl);
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: translations.buttons['click-start-exam']
|
||||
})
|
||||
.click();
|
||||
for (let i = 0; i < QUESTION_COUNT; i++) {
|
||||
await page.getByRole('radio').first().check({ force: true });
|
||||
|
||||
if (i < QUESTION_COUNT - 1) {
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: translations.buttons['next-question']
|
||||
})
|
||||
.click();
|
||||
} else {
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: translations.buttons['finish-exam']
|
||||
})
|
||||
.click();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Finish Exit exam Modal E2E Test Suite', () => {
|
||||
test('Verifies the Correct Rendering of the Finish Exit exam Modal', async ({
|
||||
page
|
||||
}) => {
|
||||
await expect(
|
||||
page.getByRole('dialog', {
|
||||
name: translations.learn.exam['finish-header']
|
||||
})
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: translations.buttons.close
|
||||
})
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page.getByText(translations.learn.exam.finish)).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: translations.learn.exam['finish-yes']
|
||||
})
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: translations.learn.exam['finish-no']
|
||||
})
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('Closes the Finish Exit exam Modal When the User clicks on exit-no button', async ({
|
||||
page
|
||||
}) => {
|
||||
await page
|
||||
.getByRole('button', { name: translations.learn.exam['exit-no'] })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('dialog', {
|
||||
name: translations.learn.exam['finish-header']
|
||||
})
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Closes the Modal when the User clicks on finish-yes button', async ({
|
||||
page
|
||||
}) => {
|
||||
await page
|
||||
.getByRole('button', { name: translations.learn.exam['finish-yes'] })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('dialog', {
|
||||
name: translations.learn.exam['finish-header']
|
||||
})
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Closes the Modal when the User clicks on X button', async ({
|
||||
page
|
||||
}) => {
|
||||
await page
|
||||
.getByRole('button', { name: translations.buttons.close })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('dialog', {
|
||||
name: translations.learn.exam['finish-header']
|
||||
})
|
||||
).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,332 @@
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"hits": [
|
||||
{
|
||||
"title": "How to Scrape Wikipedia Articles with Python",
|
||||
"author": {
|
||||
"name": "Dirk Hoekstra",
|
||||
"url": "https://www.freecodecamp.org/news/author/dirk/",
|
||||
"profileImage": "https://www.gravatar.com/avatar/70c37f72ed7bd4bde1524f41f385ee46?s=250&d=mm&r=x"
|
||||
},
|
||||
"tags": [
|
||||
{
|
||||
"name": "Web Scraping",
|
||||
"url": "https://www.freecodecamp.org/news/tag/web-scraping/"
|
||||
},
|
||||
{
|
||||
"name": "Python",
|
||||
"url": "https://www.freecodecamp.org/news/tag/python/"
|
||||
}
|
||||
],
|
||||
"url": "https://www.freecodecamp.org/news/scraping-wikipedia-articles-with-python/",
|
||||
"featureImage": "https://www.freecodecamp.org/news/content/images/2020/08/artem-maltsev-vgQFlPq8tVQ-unsplash-1.jpg",
|
||||
"publishedAt": "2020-08-24T17:24:24.000Z",
|
||||
"publishedAtTimestamp": 1598289864,
|
||||
"filterTerms": [],
|
||||
"objectID": "5f9c9913740569d1a4ca1db1",
|
||||
"_highlightResult": {
|
||||
"title": {
|
||||
"value": "How to Scrape Wikipedia __ais-highlight__Article__/ais-highlight__s with Python",
|
||||
"matchLevel": "full",
|
||||
"fullyHighlighted": false,
|
||||
"matchedWords": [
|
||||
"article"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "How to Give Feedback on a freeCodeCamp Article",
|
||||
"author": {
|
||||
"name": "freeCodeCamp.org",
|
||||
"url": "https://www.freecodecamp.org/news/author/freecodecamp/",
|
||||
"profileImage": "https://www.gravatar.com/avatar/fcda43852608626fe46d7fd43145766e?s=250&d=mm&r=x"
|
||||
},
|
||||
"tags": [
|
||||
{
|
||||
"name": "Community",
|
||||
"url": "https://www.freecodecamp.org/news/tag/community/"
|
||||
}
|
||||
],
|
||||
"url": "https://www.freecodecamp.org/news/how-to-give-feedback-on-a-freecodecamp-article/",
|
||||
"featureImage": "https://images.unsplash.com/photo-1504618223053-559bdef9dd5a?ixlib=rb-1.2.1&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=2000&fit=max&ixid=eyJhcHBfaWQiOjExNzczfQ",
|
||||
"publishedAt": "2020-04-08T17:16:00.000Z",
|
||||
"publishedAtTimestamp": 1586366160,
|
||||
"filterTerms": [],
|
||||
"objectID": "5f9c9bb8740569d1a4ca2d7f",
|
||||
"_highlightResult": {
|
||||
"title": {
|
||||
"value": "How to Give Feedback on a freeCodeCamp __ais-highlight__Article__/ais-highlight__",
|
||||
"matchLevel": "full",
|
||||
"fullyHighlighted": false,
|
||||
"matchedWords": [
|
||||
"article"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "How To Embed Multiple Choice Quiz Questions into Your Article",
|
||||
"author": {
|
||||
"name": "Alexander Arobelidze",
|
||||
"url": "https://www.freecodecamp.org/news/author/alex-arobelidze/",
|
||||
"profileImage": "https://www.freecodecamp.org/news/content/images/2020/02/fcc-1.JPG"
|
||||
},
|
||||
"tags": [
|
||||
{
|
||||
"name": "Blog",
|
||||
"url": "https://www.freecodecamp.org/news/tag/blog/"
|
||||
},
|
||||
{
|
||||
"name": "Blogging",
|
||||
"url": "https://www.freecodecamp.org/news/tag/blogging/"
|
||||
}
|
||||
],
|
||||
"url": "https://www.freecodecamp.org/news/multiple-choice-quiz-template/",
|
||||
"featureImage": "https://images.unsplash.com/photo-1501504905252-473c47e087f8?ixlib=rb-1.2.1&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=2000&fit=max&ixid=eyJhcHBfaWQiOjExNzczfQ",
|
||||
"publishedAt": "2020-04-06T08:30:53.000Z",
|
||||
"publishedAtTimestamp": 1586161853,
|
||||
"filterTerms": [],
|
||||
"objectID": "5f9c9bc5740569d1a4ca2dcf",
|
||||
"_highlightResult": {
|
||||
"title": {
|
||||
"value": "How To Embed Multiple Choice Quiz Questions into Your __ais-highlight__Article__/ais-highlight__",
|
||||
"matchLevel": "full",
|
||||
"fullyHighlighted": false,
|
||||
"matchedWords": [
|
||||
"article"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "How to Add a Table of Contents to Your Blog Post or Article",
|
||||
"author": {
|
||||
"name": "Colby Fayock",
|
||||
"url": "https://www.freecodecamp.org/news/author/colbyfayock/",
|
||||
"profileImage": "https://www.freecodecamp.org/news/content/images/2020/03/star-wars-hug-yellow-cropped.png"
|
||||
},
|
||||
"tags": [
|
||||
{
|
||||
"name": "Writing",
|
||||
"url": "https://www.freecodecamp.org/news/tag/writing/"
|
||||
},
|
||||
{
|
||||
"name": "Technical Writing",
|
||||
"url": "https://www.freecodecamp.org/news/tag/technical-writing/"
|
||||
},
|
||||
{
|
||||
"name": "Writing Tips",
|
||||
"url": "https://www.freecodecamp.org/news/tag/writing-tips/"
|
||||
},
|
||||
{
|
||||
"name": "Blog",
|
||||
"url": "https://www.freecodecamp.org/news/tag/blog/"
|
||||
},
|
||||
{
|
||||
"name": "Blogger",
|
||||
"url": "https://www.freecodecamp.org/news/tag/blogger/"
|
||||
},
|
||||
{
|
||||
"name": "Blogging",
|
||||
"url": "https://www.freecodecamp.org/news/tag/blogging/"
|
||||
},
|
||||
{
|
||||
"name": "Publishing",
|
||||
"url": "https://www.freecodecamp.org/news/tag/publishing/"
|
||||
}
|
||||
],
|
||||
"url": "https://www.freecodecamp.org/news/how-to-add-a-table-of-contents-to-your-blog-post-or-article/",
|
||||
"featureImage": "https://www.freecodecamp.org/news/content/images/2020/02/table-of-contents.jpg",
|
||||
"publishedAt": "2020-02-12T15:45:00.000Z",
|
||||
"publishedAtTimestamp": 1581522300,
|
||||
"filterTerms": [],
|
||||
"objectID": "5f9c9c9d740569d1a4ca332b",
|
||||
"_highlightResult": {
|
||||
"title": {
|
||||
"value": "How to Add a Table of Contents to Your Blog Post or __ais-highlight__Article__/ais-highlight__",
|
||||
"matchLevel": "full",
|
||||
"fullyHighlighted": false,
|
||||
"matchedWords": [
|
||||
"article"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "What 500+ blog posts taught me about writing great articles",
|
||||
"author": {
|
||||
"name": "Burke Holland",
|
||||
"url": "https://www.freecodecamp.org/news/author/burkeholland/",
|
||||
"profileImage": "https://www.gravatar.com/avatar/59247a80fdf2632dfea43b8824e07cdb?s=250&d=mm&r=x"
|
||||
},
|
||||
"tags": [
|
||||
{
|
||||
"name": "Writing",
|
||||
"url": "https://www.freecodecamp.org/news/tag/writing/"
|
||||
},
|
||||
{
|
||||
"name": "Technical Writing",
|
||||
"url": "https://www.freecodecamp.org/news/tag/technical-writing/"
|
||||
},
|
||||
{
|
||||
"name": "Blogging",
|
||||
"url": "https://www.freecodecamp.org/news/tag/blogging/"
|
||||
}
|
||||
],
|
||||
"url": "https://www.freecodecamp.org/news/what-500-blog-posts-taught-me-about-writing-great-articles/",
|
||||
"featureImage": "https://www.freecodecamp.org/news/content/images/2019/07/writing-technical-articles-banner.png",
|
||||
"publishedAt": "2019-07-31T15:00:00.000Z",
|
||||
"publishedAtTimestamp": 1564585200,
|
||||
"filterTerms": [],
|
||||
"objectID": "5f9ca121740569d1a4ca4cde",
|
||||
"_highlightResult": {
|
||||
"title": {
|
||||
"value": "What 500+ blog posts taught me about writing great __ais-highlight__article__/ais-highlight__s",
|
||||
"matchLevel": "full",
|
||||
"fullyHighlighted": false,
|
||||
"matchedWords": [
|
||||
"article"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "What 500+ blog posts taught me about writing great articles",
|
||||
"author": {
|
||||
"name": "Burke Holland",
|
||||
"url": "https://www.freecodecamp.org/news/author/burkeholland/",
|
||||
"profileImage": "https://www.gravatar.com/avatar/59247a80fdf2632dfea43b8824e07cdb?s=250&d=mm&r=x"
|
||||
},
|
||||
"tags": [
|
||||
{
|
||||
"name": "Writing",
|
||||
"url": "https://www.freecodecamp.org/news/tag/writing/"
|
||||
},
|
||||
{
|
||||
"name": "Technical Writing",
|
||||
"url": "https://www.freecodecamp.org/news/tag/technical-writing/"
|
||||
},
|
||||
{
|
||||
"name": "Blogging",
|
||||
"url": "https://www.freecodecamp.org/news/tag/blogging/"
|
||||
}
|
||||
],
|
||||
"url": "https://www.freecodecamp.org/news/what-500-blog-posts-taught-me-about-writing-great-articles/",
|
||||
"featureImage": "https://www.freecodecamp.org/news/content/images/2019/07/writing-technical-articles-banner.png",
|
||||
"publishedAt": "2019-07-31T15:00:00.000Z",
|
||||
"publishedAtTimestamp": 1564585200,
|
||||
"filterTerms": [],
|
||||
"objectID": "5f9ca121740569d1a4ca4cde",
|
||||
"_highlightResult": {
|
||||
"title": {
|
||||
"value": "What 500+ blog posts taught me about writing great __ais-highlight__article__/ais-highlight__s",
|
||||
"matchLevel": "full",
|
||||
"fullyHighlighted": false,
|
||||
"matchedWords": [
|
||||
"article"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "What 500+ blog posts taught me about writing great articles",
|
||||
"author": {
|
||||
"name": "Burke Holland",
|
||||
"url": "https://www.freecodecamp.org/news/author/burkeholland/",
|
||||
"profileImage": "https://www.gravatar.com/avatar/59247a80fdf2632dfea43b8824e07cdb?s=250&d=mm&r=x"
|
||||
},
|
||||
"tags": [
|
||||
{
|
||||
"name": "Writing",
|
||||
"url": "https://www.freecodecamp.org/news/tag/writing/"
|
||||
},
|
||||
{
|
||||
"name": "Technical Writing",
|
||||
"url": "https://www.freecodecamp.org/news/tag/technical-writing/"
|
||||
},
|
||||
{
|
||||
"name": "Blogging",
|
||||
"url": "https://www.freecodecamp.org/news/tag/blogging/"
|
||||
}
|
||||
],
|
||||
"url": "https://www.freecodecamp.org/news/what-500-blog-posts-taught-me-about-writing-great-articles/",
|
||||
"featureImage": "https://www.freecodecamp.org/news/content/images/2019/07/writing-technical-articles-banner.png",
|
||||
"publishedAt": "2019-07-31T15:00:00.000Z",
|
||||
"publishedAtTimestamp": 1564585200,
|
||||
"filterTerms": [],
|
||||
"objectID": "5f9ca121740569d1a4ca4cde",
|
||||
"_highlightResult": {
|
||||
"title": {
|
||||
"value": "What 500+ blog posts taught me about writing great __ais-highlight__article__/ais-highlight__s",
|
||||
"matchLevel": "full",
|
||||
"fullyHighlighted": false,
|
||||
"matchedWords": [
|
||||
"article"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "What 500+ blog posts taught me about writing great articles",
|
||||
"author": {
|
||||
"name": "Burke Holland",
|
||||
"url": "https://www.freecodecamp.org/news/author/burkeholland/",
|
||||
"profileImage": "https://www.gravatar.com/avatar/59247a80fdf2632dfea43b8824e07cdb?s=250&d=mm&r=x"
|
||||
},
|
||||
"tags": [
|
||||
{
|
||||
"name": "Writing",
|
||||
"url": "https://www.freecodecamp.org/news/tag/writing/"
|
||||
},
|
||||
{
|
||||
"name": "Technical Writing",
|
||||
"url": "https://www.freecodecamp.org/news/tag/technical-writing/"
|
||||
},
|
||||
{
|
||||
"name": "Blogging",
|
||||
"url": "https://www.freecodecamp.org/news/tag/blogging/"
|
||||
}
|
||||
],
|
||||
"url": "https://www.freecodecamp.org/news/what-500-blog-posts-taught-me-about-writing-great-articles/",
|
||||
"featureImage": "https://www.freecodecamp.org/news/content/images/2019/07/writing-technical-articles-banner.png",
|
||||
"publishedAt": "2019-07-31T15:00:00.000Z",
|
||||
"publishedAtTimestamp": 1564585200,
|
||||
"filterTerms": [],
|
||||
"objectID": "5f9ca121740569d1a4ca4cde",
|
||||
"_highlightResult": {
|
||||
"title": {
|
||||
"value": "What 500+ blog posts taught me about writing great __ais-highlight__article__/ais-highlight__s",
|
||||
"matchLevel": "full",
|
||||
"fullyHighlighted": false,
|
||||
"matchedWords": [
|
||||
"article"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"nbHits": 30,
|
||||
"page": 0,
|
||||
"nbPages": 6,
|
||||
"hitsPerPage": 5,
|
||||
"exhaustiveNbHits": true,
|
||||
"exhaustiveTypo": true,
|
||||
"exhaustive": {
|
||||
"nbHits": true,
|
||||
"typo": true
|
||||
},
|
||||
"query": "article",
|
||||
"params": "highlightPostTag=__%2Fais-highlight__&highlightPreTag=__ais-highlight__&hitsPerPage=5&query=article",
|
||||
"index": "news",
|
||||
"renderingContent": {},
|
||||
"processingTimeMS": 1,
|
||||
"processingTimingsMS": {
|
||||
"_request": {
|
||||
"roundTrip": 90
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"hits": [
|
||||
{
|
||||
"title": "How to Scrape Wikipedia Articles with Python",
|
||||
"author": {
|
||||
"name": "Dirk Hoekstra",
|
||||
"url": "https://www.freecodecamp.org/news/author/dirk/",
|
||||
"profileImage": "https://www.gravatar.com/avatar/70c37f72ed7bd4bde1524f41f385ee46?s=250&d=mm&r=x"
|
||||
},
|
||||
"tags": [
|
||||
{
|
||||
"name": "Web Scraping",
|
||||
"url": "https://www.freecodecamp.org/news/tag/web-scraping/"
|
||||
},
|
||||
{
|
||||
"name": "Python",
|
||||
"url": "https://www.freecodecamp.org/news/tag/python/"
|
||||
}
|
||||
],
|
||||
"url": "https://www.freecodecamp.org/news/scraping-wikipedia-articles-with-python/",
|
||||
"featureImage": "https://www.freecodecamp.org/news/content/images/2020/08/artem-maltsev-vgQFlPq8tVQ-unsplash-1.jpg",
|
||||
"publishedAt": "2020-08-24T17:24:24.000Z",
|
||||
"publishedAtTimestamp": 1598289864,
|
||||
"filterTerms": [],
|
||||
"objectID": "5f9c9913740569d1a4ca1db1",
|
||||
"_highlightResult": {
|
||||
"title": {
|
||||
"value": "How to Scrape Wikipedia __ais-highlight__Article__/ais-highlight__s with Python",
|
||||
"matchLevel": "full",
|
||||
"fullyHighlighted": false,
|
||||
"matchedWords": [
|
||||
"article"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "How to Give Feedback on a freeCodeCamp Article",
|
||||
"author": {
|
||||
"name": "freeCodeCamp.org",
|
||||
"url": "https://www.freecodecamp.org/news/author/freecodecamp/",
|
||||
"profileImage": "https://www.gravatar.com/avatar/fcda43852608626fe46d7fd43145766e?s=250&d=mm&r=x"
|
||||
},
|
||||
"tags": [
|
||||
{
|
||||
"name": "Community",
|
||||
"url": "https://www.freecodecamp.org/news/tag/community/"
|
||||
}
|
||||
],
|
||||
"url": "https://www.freecodecamp.org/news/how-to-give-feedback-on-a-freecodecamp-article/",
|
||||
"featureImage": "https://images.unsplash.com/photo-1504618223053-559bdef9dd5a?ixlib=rb-1.2.1&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=2000&fit=max&ixid=eyJhcHBfaWQiOjExNzczfQ",
|
||||
"publishedAt": "2020-04-08T17:16:00.000Z",
|
||||
"publishedAtTimestamp": 1586366160,
|
||||
"filterTerms": [],
|
||||
"objectID": "5f9c9bb8740569d1a4ca2d7f",
|
||||
"_highlightResult": {
|
||||
"title": {
|
||||
"value": "How to Give Feedback on a freeCodeCamp __ais-highlight__Article__/ais-highlight__",
|
||||
"matchLevel": "full",
|
||||
"fullyHighlighted": false,
|
||||
"matchedWords": [
|
||||
"article"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "How To Embed Multiple Choice Quiz Questions into Your Article",
|
||||
"author": {
|
||||
"name": "Alexander Arobelidze",
|
||||
"url": "https://www.freecodecamp.org/news/author/alex-arobelidze/",
|
||||
"profileImage": "https://www.freecodecamp.org/news/content/images/2020/02/fcc-1.JPG"
|
||||
},
|
||||
"tags": [
|
||||
{
|
||||
"name": "Blog",
|
||||
"url": "https://www.freecodecamp.org/news/tag/blog/"
|
||||
},
|
||||
{
|
||||
"name": "Blogging",
|
||||
"url": "https://www.freecodecamp.org/news/tag/blogging/"
|
||||
}
|
||||
],
|
||||
"url": "https://www.freecodecamp.org/news/multiple-choice-quiz-template/",
|
||||
"featureImage": "https://images.unsplash.com/photo-1501504905252-473c47e087f8?ixlib=rb-1.2.1&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=2000&fit=max&ixid=eyJhcHBfaWQiOjExNzczfQ",
|
||||
"publishedAt": "2020-04-06T08:30:53.000Z",
|
||||
"publishedAtTimestamp": 1586161853,
|
||||
"filterTerms": [],
|
||||
"objectID": "5f9c9bc5740569d1a4ca2dcf",
|
||||
"_highlightResult": {
|
||||
"title": {
|
||||
"value": "How To Embed Multiple Choice Quiz Questions into Your __ais-highlight__Article__/ais-highlight__",
|
||||
"matchLevel": "full",
|
||||
"fullyHighlighted": false,
|
||||
"matchedWords": [
|
||||
"article"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "How to Add a Table of Contents to Your Blog Post or Article",
|
||||
"author": {
|
||||
"name": "Colby Fayock",
|
||||
"url": "https://www.freecodecamp.org/news/author/colbyfayock/",
|
||||
"profileImage": "https://www.freecodecamp.org/news/content/images/2020/03/star-wars-hug-yellow-cropped.png"
|
||||
},
|
||||
"tags": [
|
||||
{
|
||||
"name": "Writing",
|
||||
"url": "https://www.freecodecamp.org/news/tag/writing/"
|
||||
},
|
||||
{
|
||||
"name": "Technical Writing",
|
||||
"url": "https://www.freecodecamp.org/news/tag/technical-writing/"
|
||||
},
|
||||
{
|
||||
"name": "Writing Tips",
|
||||
"url": "https://www.freecodecamp.org/news/tag/writing-tips/"
|
||||
},
|
||||
{
|
||||
"name": "Blog",
|
||||
"url": "https://www.freecodecamp.org/news/tag/blog/"
|
||||
},
|
||||
{
|
||||
"name": "Blogger",
|
||||
"url": "https://www.freecodecamp.org/news/tag/blogger/"
|
||||
},
|
||||
{
|
||||
"name": "Blogging",
|
||||
"url": "https://www.freecodecamp.org/news/tag/blogging/"
|
||||
},
|
||||
{
|
||||
"name": "Publishing",
|
||||
"url": "https://www.freecodecamp.org/news/tag/publishing/"
|
||||
}
|
||||
],
|
||||
"url": "https://www.freecodecamp.org/news/how-to-add-a-table-of-contents-to-your-blog-post-or-article/",
|
||||
"featureImage": "https://www.freecodecamp.org/news/content/images/2020/02/table-of-contents.jpg",
|
||||
"publishedAt": "2020-02-12T15:45:00.000Z",
|
||||
"publishedAtTimestamp": 1581522300,
|
||||
"filterTerms": [],
|
||||
"objectID": "5f9c9c9d740569d1a4ca332b",
|
||||
"_highlightResult": {
|
||||
"title": {
|
||||
"value": "How to Add a Table of Contents to Your Blog Post or __ais-highlight__Article__/ais-highlight__",
|
||||
"matchLevel": "full",
|
||||
"fullyHighlighted": false,
|
||||
"matchedWords": [
|
||||
"article"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "What 500+ blog posts taught me about writing great articles",
|
||||
"author": {
|
||||
"name": "Burke Holland",
|
||||
"url": "https://www.freecodecamp.org/news/author/burkeholland/",
|
||||
"profileImage": "https://www.gravatar.com/avatar/59247a80fdf2632dfea43b8824e07cdb?s=250&d=mm&r=x"
|
||||
},
|
||||
"tags": [
|
||||
{
|
||||
"name": "Writing",
|
||||
"url": "https://www.freecodecamp.org/news/tag/writing/"
|
||||
},
|
||||
{
|
||||
"name": "Technical Writing",
|
||||
"url": "https://www.freecodecamp.org/news/tag/technical-writing/"
|
||||
},
|
||||
{
|
||||
"name": "Blogging",
|
||||
"url": "https://www.freecodecamp.org/news/tag/blogging/"
|
||||
}
|
||||
],
|
||||
"url": "https://www.freecodecamp.org/news/what-500-blog-posts-taught-me-about-writing-great-articles/",
|
||||
"featureImage": "https://www.freecodecamp.org/news/content/images/2019/07/writing-technical-articles-banner.png",
|
||||
"publishedAt": "2019-07-31T15:00:00.000Z",
|
||||
"publishedAtTimestamp": 1564585200,
|
||||
"filterTerms": [],
|
||||
"objectID": "5f9ca121740569d1a4ca4cde",
|
||||
"_highlightResult": {
|
||||
"title": {
|
||||
"value": "What 500+ blog posts taught me about writing great __ais-highlight__article__/ais-highlight__s",
|
||||
"matchLevel": "full",
|
||||
"fullyHighlighted": false,
|
||||
"matchedWords": [
|
||||
"article"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"nbHits": 30,
|
||||
"page": 0,
|
||||
"nbPages": 6,
|
||||
"hitsPerPage": 5,
|
||||
"exhaustiveNbHits": true,
|
||||
"exhaustiveTypo": true,
|
||||
"exhaustive": {
|
||||
"nbHits": true,
|
||||
"typo": true
|
||||
},
|
||||
"query": "article",
|
||||
"params": "highlightPostTag=__%2Fais-highlight__&highlightPreTag=__ais-highlight__&hitsPerPage=5&query=article",
|
||||
"index": "news",
|
||||
"renderingContent": {},
|
||||
"processingTimeMS": 1,
|
||||
"processingTimingsMS": {
|
||||
"_request": {
|
||||
"roundTrip": 90
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"hits": [],
|
||||
"nbHits": 0,
|
||||
"page": 0,
|
||||
"nbPages": 0,
|
||||
"hitsPerPage": 5,
|
||||
"exhaustiveNbHits": true,
|
||||
"exhaustiveTypo": true,
|
||||
"exhaustive": {
|
||||
"nbHits": true,
|
||||
"typo": true
|
||||
},
|
||||
"query": "sdefpuhsdfpiouhdsfgp",
|
||||
"params": "highlightPostTag=__%2Fais-highlight__&highlightPreTag=__ais-highlight__&hitsPerPage=5&query=sdefpuhsdfpiouhdsfgp",
|
||||
"index": "news",
|
||||
"renderingContent": {},
|
||||
"processingTimeMS": 1,
|
||||
"processingTimingsMS": {
|
||||
"_request": {
|
||||
"roundTrip": 94
|
||||
},
|
||||
"total": 1
|
||||
},
|
||||
"serverTimeMS": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"content": "<head><style>@media (max-width: 500px){nav{display: none;}}</style></head><body><nav id=\"navbar\"><a href=\"#projects\">text</a> | </nav><main><section id=\"welcome-section\"><h1>text</h1></section><hr><section id=\"projects\"><h1>Projects</h1><h2 class=\"project-tile\"><a id=\"profile-link\" target=\"_blank\" href=\"https://freecodecamp.org\">text</a></h2></section><hr></body></html>"
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"id": "pm_1PRWUAFI1KEgscdTFyjUCbcd",
|
||||
"object": "payment_method",
|
||||
"allow_redisplay": "unspecified",
|
||||
"billing_details": {
|
||||
"address": {
|
||||
"city": null,
|
||||
"country": null,
|
||||
"line1": null,
|
||||
"line2": null,
|
||||
"postal_code": null,
|
||||
"state": null
|
||||
},
|
||||
"email": null,
|
||||
"name": null,
|
||||
"phone": null
|
||||
},
|
||||
"card": {
|
||||
"brand": "visa",
|
||||
"checks": {
|
||||
"address_line1_check": null,
|
||||
"address_postal_code_check": null,
|
||||
"cvc_check": null
|
||||
},
|
||||
"country": "US",
|
||||
"display_brand": "visa",
|
||||
"exp_month": 10,
|
||||
"exp_year": 2025,
|
||||
"funding": "credit",
|
||||
"generated_from": null,
|
||||
"last4": "4242",
|
||||
"networks": {
|
||||
"available": [
|
||||
"visa"
|
||||
],
|
||||
"preferred": null
|
||||
},
|
||||
"three_d_secure_usage": {
|
||||
"supported": true
|
||||
},
|
||||
"wallet": null
|
||||
},
|
||||
"created": 1718357514,
|
||||
"customer": null,
|
||||
"livemode": false,
|
||||
"radar_options": {},
|
||||
"type": "card"
|
||||
}
|
||||
@@ -0,0 +1,646 @@
|
||||
{
|
||||
"javascript-algorithms-and-data-structures-projects": {
|
||||
"meta": {
|
||||
"name": "JavaScript Algorithms and Data Structures Projects",
|
||||
"isUpcomingChange": false,
|
||||
"dashedName": "javascript-algorithms-and-data-structures-projects",
|
||||
"helpCategory": "JavaScript",
|
||||
"order": 9,
|
||||
"time": "50 hours",
|
||||
"template": "",
|
||||
"required": [],
|
||||
"superBlock": "javascript-algorithms-and-data-structures",
|
||||
"challengeOrder": [
|
||||
{
|
||||
"id": "aaa48de84e1ecc7c742e1124",
|
||||
"title": "Palindrome Checker"
|
||||
},
|
||||
{
|
||||
"id": "a7f4d8f2483413a6ce226cac",
|
||||
"title": "Roman Numeral Converter"
|
||||
},
|
||||
{
|
||||
"id": "56533eb9ac21ba0edf2244e2",
|
||||
"title": "Caesars Cipher"
|
||||
},
|
||||
{
|
||||
"id": "aff0395860f5d3034dc0bfc9",
|
||||
"title": "Telephone Number Validator"
|
||||
},
|
||||
{
|
||||
"id": "aa2e6f85cab2ab736c9a9b24",
|
||||
"title": "Cash Register"
|
||||
}
|
||||
]
|
||||
},
|
||||
"challenges": [
|
||||
{
|
||||
"id": "56533eb9ac21ba0edf2244e2",
|
||||
"title": "Caesars Cipher",
|
||||
"challengeType": 5,
|
||||
"forumTopicId": 16003,
|
||||
"dashedName": "caesars-cipher",
|
||||
"challengeFiles": [
|
||||
{
|
||||
"head": "",
|
||||
"tail": "",
|
||||
"id": "",
|
||||
"editableRegionBoundaries": [],
|
||||
"history": ["script.js"],
|
||||
"name": "script",
|
||||
"ext": "js",
|
||||
"path": "script.js",
|
||||
"fileKey": "scriptjs",
|
||||
"contents": "function rot13(str) {\n return str;\n}\n\nrot13(\"SERR PBQR PNZC\");",
|
||||
"error": null,
|
||||
"seed": "function rot13(str) {\n return str;\n}\n\nrot13(\"SERR PBQR PNZC\");"
|
||||
}
|
||||
],
|
||||
"solutions": [
|
||||
[
|
||||
{
|
||||
"head": "",
|
||||
"tail": "",
|
||||
"id": "",
|
||||
"history": ["script.js"],
|
||||
"name": "script",
|
||||
"ext": "js",
|
||||
"path": "script.js",
|
||||
"fileKey": "scriptjs",
|
||||
"contents": "var lookup = {\n 'A': 'N','B': 'O','C': 'P','D': 'Q',\n 'E': 'R','F': 'S','G': 'T','H': 'U',\n 'I': 'V','J': 'W','K': 'X','L': 'Y',\n 'M': 'Z','N': 'A','O': 'B','P': 'C',\n 'Q': 'D','R': 'E','S': 'F','T': 'G',\n 'U': 'H','V': 'I','W': 'J','X': 'K',\n 'Y': 'L','Z': 'M'\n};\n\nfunction rot13(encodedStr) {\n var codeArr = encodedStr.split(\"\"); // String to Array\n var decodedArr = []; // Your Result goes here\n // Only change code below this line\n\n decodedArr = codeArr.map(function(letter) {\n if(lookup.hasOwnProperty(letter)) {\n letter = lookup[letter];\n }\n return letter;\n });\n\n // Only change code above this line\n return decodedArr.join(\"\"); // Array to String\n}",
|
||||
"error": null,
|
||||
"seed": "var lookup = {\n 'A': 'N','B': 'O','C': 'P','D': 'Q',\n 'E': 'R','F': 'S','G': 'T','H': 'U',\n 'I': 'V','J': 'W','K': 'X','L': 'Y',\n 'M': 'Z','N': 'A','O': 'B','P': 'C',\n 'Q': 'D','R': 'E','S': 'F','T': 'G',\n 'U': 'H','V': 'I','W': 'J','X': 'K',\n 'Y': 'L','Z': 'M'\n};\n\nfunction rot13(encodedStr) {\n var codeArr = encodedStr.split(\"\"); // String to Array\n var decodedArr = []; // Your Result goes here\n // Only change code below this line\n\n decodedArr = codeArr.map(function(letter) {\n if(lookup.hasOwnProperty(letter)) {\n letter = lookup[letter];\n }\n return letter;\n });\n\n // Only change code above this line\n return decodedArr.join(\"\"); // Array to String\n}"
|
||||
}
|
||||
]
|
||||
],
|
||||
"assignments": [],
|
||||
"tests": [
|
||||
{
|
||||
"text": "<p><code>rot13(\"SERR PBQR PNZC\")</code> should decode to the string <code>FREE CODE CAMP</code></p>",
|
||||
"testString": "assert(rot13('SERR PBQR PNZC') === 'FREE CODE CAMP');"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>rot13(\"SERR CVMMN!\")</code> should decode to the string <code>FREE PIZZA!</code></p>",
|
||||
"testString": "assert(rot13('SERR CVMMN!') === 'FREE PIZZA!');"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>rot13(\"SERR YBIR?\")</code> should decode to the string <code>FREE LOVE?</code></p>",
|
||||
"testString": "assert(rot13('SERR YBIR?') === 'FREE LOVE?');"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>rot13(\"GUR DHVPX OEBJA SBK WHZCF BIRE GUR YNML QBT.\")</code> should decode to the string <code>THE QUICK BROWN FOX JUMPS OVER THE LAZY DOG.</code></p>",
|
||||
"testString": "assert(\n rot13('GUR DHVPX OEBJA SBK WHZCF BIRE GUR YNML QBT.') ===\n 'THE QUICK BROWN FOX JUMPS OVER THE LAZY DOG.'\n);"
|
||||
}
|
||||
],
|
||||
"description": "<section id=\"description\">\n<p>One of the simplest and most widely known <dfn>ciphers</dfn> is a <dfn>Caesar cipher</dfn>, also known as a <dfn>shift cipher</dfn>. In a shift cipher the meanings of the letters are shifted by some set amount.</p>\n<p>A common modern use is the <a href=\"https://www.freecodecamp.org/news/how-to-code-the-caesar-cipher-an-introduction-to-basic-encryption-3bf77b4e19f7/\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">ROT13</a> cipher, where the values of the letters are shifted by 13 places. Thus <code>A ↔ N</code>, <code>B ↔ O</code> and so on.</p>\n<p>Write a function which takes a <a href=\"https://www.freecodecamp.org/news/how-to-code-the-caesar-cipher-an-introduction-to-basic-encryption-3bf77b4e19f7/\" target=\"_blank\" rel=\"noopener noreferrer nofollow\">ROT13</a> encoded string as input and returns a decoded string.</p>\n<p>All letters will be uppercase. Do not transform any non-alphabetic character (i.e. spaces, punctuation), but do pass them on.</p>\n</section>",
|
||||
"translationPending": false,
|
||||
"block": "javascript-algorithms-and-data-structures-projects",
|
||||
"hasEditableBoundaries": false,
|
||||
"order": 9,
|
||||
"superOrder": 19,
|
||||
"certification": "javascript-algorithms-and-data-structures",
|
||||
"superBlock": "javascript-algorithms-and-data-structures",
|
||||
"challengeOrder": 2,
|
||||
"required": [],
|
||||
"template": "",
|
||||
"time": "50 hours",
|
||||
"helpCategory": "JavaScript",
|
||||
"usesMultifileEditor": false,
|
||||
"disableLoopProtectTests": false,
|
||||
"disableLoopProtectPreview": false
|
||||
},
|
||||
{
|
||||
"id": "aa2e6f85cab2ab736c9a9b24",
|
||||
"title": "Cash Register",
|
||||
"challengeType": 5,
|
||||
"forumTopicId": 16012,
|
||||
"dashedName": "cash-register",
|
||||
"challengeFiles": [
|
||||
{
|
||||
"head": "",
|
||||
"tail": "",
|
||||
"id": "",
|
||||
"editableRegionBoundaries": [],
|
||||
"history": ["script.js"],
|
||||
"name": "script",
|
||||
"ext": "js",
|
||||
"path": "script.js",
|
||||
"fileKey": "scriptjs",
|
||||
"contents": "function checkCashRegister(price, cash, cid) {\n let change;\n return change;\n}\n\ncheckCashRegister(19.5, 20, [[\"PENNY\", 1.01], [\"NICKEL\", 2.05], [\"DIME\", 3.1], [\"QUARTER\", 4.25], [\"ONE\", 90], [\"FIVE\", 55], [\"TEN\", 20], [\"TWENTY\", 60], [\"ONE HUNDRED\", 100]]);",
|
||||
"error": null,
|
||||
"seed": "function checkCashRegister(price, cash, cid) {\n let change;\n return change;\n}\n\ncheckCashRegister(19.5, 20, [[\"PENNY\", 1.01], [\"NICKEL\", 2.05], [\"DIME\", 3.1], [\"QUARTER\", 4.25], [\"ONE\", 90], [\"FIVE\", 55], [\"TEN\", 20], [\"TWENTY\", 60], [\"ONE HUNDRED\", 100]]);"
|
||||
}
|
||||
],
|
||||
"solutions": [
|
||||
[
|
||||
{
|
||||
"head": "",
|
||||
"tail": "",
|
||||
"id": "",
|
||||
"history": ["script.js"],
|
||||
"name": "script",
|
||||
"ext": "js",
|
||||
"path": "script.js",
|
||||
"fileKey": "scriptjs",
|
||||
"contents": "const denom = [\n { name: \"ONE HUNDRED\", val: 100 },\n { name: \"TWENTY\", val: 20 },\n { name: \"TEN\", val: 10 },\n { name: \"FIVE\", val: 5 },\n { name: \"ONE\", val: 1 },\n { name: \"QUARTER\", val: 0.25 },\n { name: \"DIME\", val: 0.1 },\n { name: \"NICKEL\", val: 0.05 },\n { name: \"PENNY\", val: 0.01 },\n];\n\nfunction checkCashRegister(price, cash, cid) {\n const output = { status: null, change: [] };\n let change = cash - price;\n const register = cid.reduce(\n function (acc, curr) {\n acc.total += curr[1];\n acc[curr[0]] = curr[1];\n return acc;\n },\n { total: 0 }\n );\n if (register.total === change) {\n output.status = \"CLOSED\";\n output.change = cid;\n return output;\n }\n if (register.total < change) {\n output.status = \"INSUFFICIENT_FUNDS\";\n return output;\n }\n const change_arr = denom.reduce(function (acc, curr) {\n let value = 0;\n while (register[curr.name] > 0 && change >= curr.val) {\n change -= curr.val;\n register[curr.name] -= curr.val;\n value += curr.val;\n change = Math.round(change * 100) / 100;\n }\n if (value > 0) {\n acc.push([curr.name, value]);\n }\n return acc;\n }, []);\n if (change_arr.length < 1 || change > 0) {\n output.status = \"INSUFFICIENT_FUNDS\";\n return output;\n }\n output.status = \"OPEN\";\n output.change = change_arr;\n return output;\n}",
|
||||
"error": null,
|
||||
"seed": "const denom = [\n { name: \"ONE HUNDRED\", val: 100 },\n { name: \"TWENTY\", val: 20 },\n { name: \"TEN\", val: 10 },\n { name: \"FIVE\", val: 5 },\n { name: \"ONE\", val: 1 },\n { name: \"QUARTER\", val: 0.25 },\n { name: \"DIME\", val: 0.1 },\n { name: \"NICKEL\", val: 0.05 },\n { name: \"PENNY\", val: 0.01 },\n];\n\nfunction checkCashRegister(price, cash, cid) {\n const output = { status: null, change: [] };\n let change = cash - price;\n const register = cid.reduce(\n function (acc, curr) {\n acc.total += curr[1];\n acc[curr[0]] = curr[1];\n return acc;\n },\n { total: 0 }\n );\n if (register.total === change) {\n output.status = \"CLOSED\";\n output.change = cid;\n return output;\n }\n if (register.total < change) {\n output.status = \"INSUFFICIENT_FUNDS\";\n return output;\n }\n const change_arr = denom.reduce(function (acc, curr) {\n let value = 0;\n while (register[curr.name] > 0 && change >= curr.val) {\n change -= curr.val;\n register[curr.name] -= curr.val;\n value += curr.val;\n change = Math.round(change * 100) / 100;\n }\n if (value > 0) {\n acc.push([curr.name, value]);\n }\n return acc;\n }, []);\n if (change_arr.length < 1 || change > 0) {\n output.status = \"INSUFFICIENT_FUNDS\";\n return output;\n }\n output.status = \"OPEN\";\n output.change = change_arr;\n return output;\n}"
|
||||
}
|
||||
]
|
||||
],
|
||||
"assignments": [],
|
||||
"tests": [
|
||||
{
|
||||
"text": "<p><code>checkCashRegister(19.5, 20, [[\"PENNY\", 1.01], [\"NICKEL\", 2.05], [\"DIME\", 3.1], [\"QUARTER\", 4.25], [\"ONE\", 90], [\"FIVE\", 55], [\"TEN\", 20], [\"TWENTY\", 60], [\"ONE HUNDRED\", 100]])</code> should return an object.</p>",
|
||||
"testString": "assert.deepEqual(\n Object.prototype.toString.call(\n checkCashRegister(19.5, 20, [\n ['PENNY', 1.01],\n ['NICKEL', 2.05],\n ['DIME', 3.1],\n ['QUARTER', 4.25],\n ['ONE', 90],\n ['FIVE', 55],\n ['TEN', 20],\n ['TWENTY', 60],\n ['ONE HUNDRED', 100]\n ])\n ),\n '[object Object]'\n);"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>checkCashRegister(19.5, 20, [[\"PENNY\", 1.01], [\"NICKEL\", 2.05], [\"DIME\", 3.1], [\"QUARTER\", 4.25], [\"ONE\", 90], [\"FIVE\", 55], [\"TEN\", 20], [\"TWENTY\", 60], [\"ONE HUNDRED\", 100]])</code> should return <code>{status: \"OPEN\", change: [[\"QUARTER\", 0.5]]}</code>.</p>",
|
||||
"testString": "assert.deepEqual(\n checkCashRegister(19.5, 20, [\n ['PENNY', 1.01],\n ['NICKEL', 2.05],\n ['DIME', 3.1],\n ['QUARTER', 4.25],\n ['ONE', 90],\n ['FIVE', 55],\n ['TEN', 20],\n ['TWENTY', 60],\n ['ONE HUNDRED', 100]\n ]),\n { status: 'OPEN', change: [['QUARTER', 0.5]] }\n);"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>checkCashRegister(3.26, 100, [[\"PENNY\", 1.01], [\"NICKEL\", 2.05], [\"DIME\", 3.1], [\"QUARTER\", 4.25], [\"ONE\", 90], [\"FIVE\", 55], [\"TEN\", 20], [\"TWENTY\", 60], [\"ONE HUNDRED\", 100]])</code> should return <code>{status: \"OPEN\", change: [[\"TWENTY\", 60], [\"TEN\", 20], [\"FIVE\", 15], [\"ONE\", 1], [\"QUARTER\", 0.5], [\"DIME\", 0.2], [\"PENNY\", 0.04]]}</code>.</p>",
|
||||
"testString": "assert.deepEqual(\n checkCashRegister(3.26, 100, [\n ['PENNY', 1.01],\n ['NICKEL', 2.05],\n ['DIME', 3.1],\n ['QUARTER', 4.25],\n ['ONE', 90],\n ['FIVE', 55],\n ['TEN', 20],\n ['TWENTY', 60],\n ['ONE HUNDRED', 100]\n ]),\n {\n status: 'OPEN',\n change: [\n ['TWENTY', 60],\n ['TEN', 20],\n ['FIVE', 15],\n ['ONE', 1],\n ['QUARTER', 0.5],\n ['DIME', 0.2],\n ['PENNY', 0.04]\n ]\n }\n);"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>checkCashRegister(19.5, 20, [[\"PENNY\", 0.01], [\"NICKEL\", 0], [\"DIME\", 0], [\"QUARTER\", 0], [\"ONE\", 0], [\"FIVE\", 0], [\"TEN\", 0], [\"TWENTY\", 0], [\"ONE HUNDRED\", 0]])</code> should return <code>{status: \"INSUFFICIENT_FUNDS\", change: []}</code>.</p>",
|
||||
"testString": "assert.deepEqual(\n checkCashRegister(19.5, 20, [\n ['PENNY', 0.01],\n ['NICKEL', 0],\n ['DIME', 0],\n ['QUARTER', 0],\n ['ONE', 0],\n ['FIVE', 0],\n ['TEN', 0],\n ['TWENTY', 0],\n ['ONE HUNDRED', 0]\n ]),\n { status: 'INSUFFICIENT_FUNDS', change: [] }\n);"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>checkCashRegister(19.5, 20, [[\"PENNY\", 0.01], [\"NICKEL\", 0], [\"DIME\", 0], [\"QUARTER\", 0], [\"ONE\", 1], [\"FIVE\", 0], [\"TEN\", 0], [\"TWENTY\", 0], [\"ONE HUNDRED\", 0]])</code> should return <code>{status: \"INSUFFICIENT_FUNDS\", change: []}</code>.</p>",
|
||||
"testString": "assert.deepEqual(\n checkCashRegister(19.5, 20, [\n ['PENNY', 0.01],\n ['NICKEL', 0],\n ['DIME', 0],\n ['QUARTER', 0],\n ['ONE', 1],\n ['FIVE', 0],\n ['TEN', 0],\n ['TWENTY', 0],\n ['ONE HUNDRED', 0]\n ]),\n { status: 'INSUFFICIENT_FUNDS', change: [] }\n);"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>checkCashRegister(19.5, 20, [[\"PENNY\", 0.5], [\"NICKEL\", 0], [\"DIME\", 0], [\"QUARTER\", 0], [\"ONE\", 0], [\"FIVE\", 0], [\"TEN\", 0], [\"TWENTY\", 0], [\"ONE HUNDRED\", 0]])</code> should return <code>{status: \"CLOSED\", change: [[\"PENNY\", 0.5], [\"NICKEL\", 0], [\"DIME\", 0], [\"QUARTER\", 0], [\"ONE\", 0], [\"FIVE\", 0], [\"TEN\", 0], [\"TWENTY\", 0], [\"ONE HUNDRED\", 0]]}</code>.</p>",
|
||||
"testString": "assert.deepEqual(\n checkCashRegister(19.5, 20, [\n ['PENNY', 0.5],\n ['NICKEL', 0],\n ['DIME', 0],\n ['QUARTER', 0],\n ['ONE', 0],\n ['FIVE', 0],\n ['TEN', 0],\n ['TWENTY', 0],\n ['ONE HUNDRED', 0]\n ]),\n {\n status: 'CLOSED',\n change: [\n ['PENNY', 0.5],\n ['NICKEL', 0],\n ['DIME', 0],\n ['QUARTER', 0],\n ['ONE', 0],\n ['FIVE', 0],\n ['TEN', 0],\n ['TWENTY', 0],\n ['ONE HUNDRED', 0]\n ]\n }\n);"
|
||||
}
|
||||
],
|
||||
"description": "<section id=\"description\">\n<p>Design a cash register drawer function <code>checkCashRegister()</code> that accepts purchase price as the first argument (<code>price</code>), payment as the second argument (<code>cash</code>), and cash-in-drawer (<code>cid</code>) as the third argument.</p>\n<p><code>cid</code> is a 2D array listing available currency.</p>\n<p>The <code>checkCashRegister()</code> function should always return an object with a <code>status</code> key and a <code>change</code> key.</p>\n<p>Return <code>{status: \"INSUFFICIENT_FUNDS\", change: []}</code> if cash-in-drawer is less than the change due, or if you cannot return the exact change.</p>\n<p>Return <code>{status: \"CLOSED\", change: [...]}</code> with cash-in-drawer as the value for the key <code>change</code> if it is equal to the change due.</p>\n<p>Otherwise, return <code>{status: \"OPEN\", change: [...]}</code>, with the change due in coins and bills, sorted in highest to lowest order, as the value of the <code>change</code> key.</p>\n<table><tbody><tr><th>Currency Unit</th><th>Amount</th></tr><tr><td>Penny</td><td>$0.01 (PENNY)</td></tr><tr><td>Nickel</td><td>$0.05 (NICKEL)</td></tr><tr><td>Dime</td><td>$0.1 (DIME)</td></tr><tr><td>Quarter</td><td>$0.25 (QUARTER)</td></tr><tr><td>Dollar</td><td>$1 (ONE)</td></tr><tr><td>Five Dollars</td><td>$5 (FIVE)</td></tr><tr><td>Ten Dollars</td><td>$10 (TEN)</td></tr><tr><td>Twenty Dollars</td><td>$20 (TWENTY)</td></tr><tr><td>One-hundred Dollars</td><td>$100 (ONE HUNDRED)</td></tr></tbody></table>\n<p>See below for an example of a cash-in-drawer array:</p>\n<pre><code class=\"language-js\">[\n [\"PENNY\", 1.01],\n [\"NICKEL\", 2.05],\n [\"DIME\", 3.1],\n [\"QUARTER\", 4.25],\n [\"ONE\", 90],\n [\"FIVE\", 55],\n [\"TEN\", 20],\n [\"TWENTY\", 60],\n [\"ONE HUNDRED\", 100]\n]\n</code></pre>\n</section>",
|
||||
"translationPending": false,
|
||||
"block": "javascript-algorithms-and-data-structures-projects",
|
||||
"hasEditableBoundaries": false,
|
||||
"order": 9,
|
||||
"superOrder": 19,
|
||||
"certification": "javascript-algorithms-and-data-structures",
|
||||
"superBlock": "javascript-algorithms-and-data-structures",
|
||||
"challengeOrder": 4,
|
||||
"required": [],
|
||||
"template": "",
|
||||
"time": "50 hours",
|
||||
"helpCategory": "JavaScript",
|
||||
"usesMultifileEditor": false,
|
||||
"disableLoopProtectTests": false,
|
||||
"disableLoopProtectPreview": false
|
||||
},
|
||||
{
|
||||
"id": "aaa48de84e1ecc7c742e1124",
|
||||
"title": "Palindrome Checker",
|
||||
"challengeType": 5,
|
||||
"forumTopicId": 16004,
|
||||
"dashedName": "palindrome-checker",
|
||||
"challengeFiles": [
|
||||
{
|
||||
"head": "",
|
||||
"tail": "",
|
||||
"id": "",
|
||||
"editableRegionBoundaries": [],
|
||||
"history": ["script.js"],
|
||||
"name": "script",
|
||||
"ext": "js",
|
||||
"path": "script.js",
|
||||
"fileKey": "scriptjs",
|
||||
"contents": "function palindrome(str) {\n return true;\n}\n\npalindrome(\"eye\");",
|
||||
"error": null,
|
||||
"seed": "function palindrome(str) {\n return true;\n}\n\npalindrome(\"eye\");"
|
||||
}
|
||||
],
|
||||
"solutions": [
|
||||
[
|
||||
{
|
||||
"head": "",
|
||||
"tail": "",
|
||||
"id": "",
|
||||
"history": ["script.js"],
|
||||
"name": "script",
|
||||
"ext": "js",
|
||||
"path": "script.js",
|
||||
"fileKey": "scriptjs",
|
||||
"contents": "function palindrome(str) {\n var string = str.toLowerCase().split(/[^A-Za-z0-9]/gi).join('');\n var aux = string.split('');\n if (aux.join('') === aux.reverse().join('')){\n return true;\n }\n\n return false;\n}",
|
||||
"error": null,
|
||||
"seed": "function palindrome(str) {\n var string = str.toLowerCase().split(/[^A-Za-z0-9]/gi).join('');\n var aux = string.split('');\n if (aux.join('') === aux.reverse().join('')){\n return true;\n }\n\n return false;\n}"
|
||||
}
|
||||
]
|
||||
],
|
||||
"assignments": [],
|
||||
"tests": [
|
||||
{
|
||||
"text": "<p><code>palindrome(\"eye\")</code> should return a boolean.</p>",
|
||||
"testString": "assert(typeof palindrome('eye') === 'boolean');"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>palindrome(\"eye\")</code> should return <code>true</code>.</p>",
|
||||
"testString": "assert(palindrome('eye') === true);"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>palindrome(\"_eye\")</code> should return <code>true</code>.</p>",
|
||||
"testString": "assert(palindrome('_eye') === true);"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>palindrome(\"race car\")</code> should return <code>true</code>.</p>",
|
||||
"testString": "assert(palindrome('race car') === true);"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>palindrome(\"not a palindrome\")</code> should return <code>false</code>.</p>",
|
||||
"testString": "assert(palindrome('not a palindrome') === false);"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>palindrome(\"A man, a plan, a canal. Panama\")</code> should return <code>true</code>.</p>",
|
||||
"testString": "assert(palindrome('A man, a plan, a canal. Panama') === true);"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>palindrome(\"never odd or even\")</code> should return <code>true</code>.</p>",
|
||||
"testString": "assert(palindrome('never odd or even') === true);"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>palindrome(\"nope\")</code> should return <code>false</code>.</p>",
|
||||
"testString": "assert(palindrome('nope') === false);"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>palindrome(\"almostomla\")</code> should return <code>false</code>.</p>",
|
||||
"testString": "assert(palindrome('almostomla') === false);"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>palindrome(\"My age is 0, 0 si ega ym.\")</code> should return <code>true</code>.</p>",
|
||||
"testString": "assert(palindrome('My age is 0, 0 si ega ym.') === true);"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>palindrome(\"1 eye for of 1 eye.\")</code> should return <code>false</code>.</p>",
|
||||
"testString": "assert(palindrome('1 eye for of 1 eye.') === false);"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>palindrome(\"0_0 (: /-\\ :) 0-0\")</code> should return <code>true</code>.</p>",
|
||||
"testString": "assert(palindrome('0_0 (: /- :) 0-0') === true);"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>palindrome(\"five|\\_/|four\")</code> should return <code>false</code>.</p>",
|
||||
"testString": "assert(palindrome('five|_/|four') === false);"
|
||||
}
|
||||
],
|
||||
"description": "<section id=\"description\">\n<p>Return <code>true</code> if the given string is a palindrome. Otherwise, return <code>false</code>.</p>\n<p>A <dfn>palindrome</dfn> is a word or sentence that's spelled the same way both forward and backward, ignoring punctuation, case, and spacing.</p>\n<p><strong>Note:</strong> You'll need to remove <strong>all non-alphanumeric characters</strong> (punctuation, spaces and symbols) and turn everything into the same case (lower or upper case) in order to check for palindromes.</p>\n<p>We'll pass strings with varying formats, such as <code>racecar</code>, <code>RaceCar</code>, and <code>race CAR</code> among others.</p>\n<p>We'll also pass strings with special symbols, such as <code>2A3*3a2</code>, <code>2A3 3a2</code>, and <code>2_A3*3#A2</code>.</p>\n</section>",
|
||||
"translationPending": false,
|
||||
"block": "javascript-algorithms-and-data-structures-projects",
|
||||
"hasEditableBoundaries": false,
|
||||
"order": 9,
|
||||
"superOrder": 19,
|
||||
"certification": "javascript-algorithms-and-data-structures",
|
||||
"superBlock": "javascript-algorithms-and-data-structures",
|
||||
"challengeOrder": 0,
|
||||
"required": [],
|
||||
"template": "",
|
||||
"time": "50 hours",
|
||||
"helpCategory": "JavaScript",
|
||||
"usesMultifileEditor": false,
|
||||
"disableLoopProtectTests": false,
|
||||
"disableLoopProtectPreview": false
|
||||
},
|
||||
{
|
||||
"id": "a7f4d8f2483413a6ce226cac",
|
||||
"title": "Roman Numeral Converter",
|
||||
"challengeType": 5,
|
||||
"forumTopicId": 16044,
|
||||
"dashedName": "roman-numeral-converter",
|
||||
"challengeFiles": [
|
||||
{
|
||||
"head": "",
|
||||
"tail": "",
|
||||
"id": "",
|
||||
"editableRegionBoundaries": [],
|
||||
"history": ["script.js"],
|
||||
"name": "script",
|
||||
"ext": "js",
|
||||
"path": "script.js",
|
||||
"fileKey": "scriptjs",
|
||||
"contents": "function convertToRoman(num) {\n return num;\n}\n\nconvertToRoman(36);",
|
||||
"error": null,
|
||||
"seed": "function convertToRoman(num) {\n return num;\n}\n\nconvertToRoman(36);"
|
||||
}
|
||||
],
|
||||
"solutions": [
|
||||
[
|
||||
{
|
||||
"head": "",
|
||||
"tail": "",
|
||||
"id": "",
|
||||
"history": ["script.js"],
|
||||
"name": "script",
|
||||
"ext": "js",
|
||||
"path": "script.js",
|
||||
"fileKey": "scriptjs",
|
||||
"contents": "function convertToRoman(num) {\n var ref = [['M', 1000], ['CM', 900], ['D', 500], ['CD', 400], ['C', 100], ['XC', 90], ['L', 50], ['XL', 40], ['X', 10], ['IX', 9], ['V', 5], ['IV', 4], ['I', 1]];\n var res = [];\n ref.forEach(function(p) {\n while (num >= p[1]) {\n res.push(p[0]);\n num -= p[1];\n }\n });\n return res.join('');\n}",
|
||||
"error": null,
|
||||
"seed": "function convertToRoman(num) {\n var ref = [['M', 1000], ['CM', 900], ['D', 500], ['CD', 400], ['C', 100], ['XC', 90], ['L', 50], ['XL', 40], ['X', 10], ['IX', 9], ['V', 5], ['IV', 4], ['I', 1]];\n var res = [];\n ref.forEach(function(p) {\n while (num >= p[1]) {\n res.push(p[0]);\n num -= p[1];\n }\n });\n return res.join('');\n}"
|
||||
}
|
||||
]
|
||||
],
|
||||
"assignments": [],
|
||||
"tests": [
|
||||
{
|
||||
"text": "<p><code>convertToRoman(2)</code> should return the string <code>II</code>.</p>",
|
||||
"testString": "assert.deepEqual(convertToRoman(2), 'II');"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>convertToRoman(3)</code> should return the string <code>III</code>.</p>",
|
||||
"testString": "assert.deepEqual(convertToRoman(3), 'III');"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>convertToRoman(4)</code> should return the string <code>IV</code>.</p>",
|
||||
"testString": "assert.deepEqual(convertToRoman(4), 'IV');"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>convertToRoman(5)</code> should return the string <code>V</code>.</p>",
|
||||
"testString": "assert.deepEqual(convertToRoman(5), 'V');"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>convertToRoman(9)</code> should return the string <code>IX</code>.</p>",
|
||||
"testString": "assert.deepEqual(convertToRoman(9), 'IX');"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>convertToRoman(12)</code> should return the string <code>XII</code>.</p>",
|
||||
"testString": "assert.deepEqual(convertToRoman(12), 'XII');"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>convertToRoman(16)</code> should return the string <code>XVI</code>.</p>",
|
||||
"testString": "assert.deepEqual(convertToRoman(16), 'XVI');"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>convertToRoman(29)</code> should return the string <code>XXIX</code>.</p>",
|
||||
"testString": "assert.deepEqual(convertToRoman(29), 'XXIX');"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>convertToRoman(44)</code> should return the string <code>XLIV</code>.</p>",
|
||||
"testString": "assert.deepEqual(convertToRoman(44), 'XLIV');"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>convertToRoman(45)</code> should return the string <code>XLV</code>.</p>",
|
||||
"testString": "assert.deepEqual(convertToRoman(45), 'XLV');"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>convertToRoman(68)</code> should return the string <code>LXVIII</code></p>",
|
||||
"testString": "assert.deepEqual(convertToRoman(68), 'LXVIII');"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>convertToRoman(83)</code> should return the string <code>LXXXIII</code></p>",
|
||||
"testString": "assert.deepEqual(convertToRoman(83), 'LXXXIII');"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>convertToRoman(97)</code> should return the string <code>XCVII</code></p>",
|
||||
"testString": "assert.deepEqual(convertToRoman(97), 'XCVII');"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>convertToRoman(99)</code> should return the string <code>XCIX</code></p>",
|
||||
"testString": "assert.deepEqual(convertToRoman(99), 'XCIX');"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>convertToRoman(400)</code> should return the string <code>CD</code></p>",
|
||||
"testString": "assert.deepEqual(convertToRoman(400), 'CD');"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>convertToRoman(500)</code> should return the string <code>D</code></p>",
|
||||
"testString": "assert.deepEqual(convertToRoman(500), 'D');"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>convertToRoman(501)</code> should return the string <code>DI</code></p>",
|
||||
"testString": "assert.deepEqual(convertToRoman(501), 'DI');"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>convertToRoman(649)</code> should return the string <code>DCXLIX</code></p>",
|
||||
"testString": "assert.deepEqual(convertToRoman(649), 'DCXLIX');"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>convertToRoman(798)</code> should return the string <code>DCCXCVIII</code></p>",
|
||||
"testString": "assert.deepEqual(convertToRoman(798), 'DCCXCVIII');"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>convertToRoman(891)</code> should return the string <code>DCCCXCI</code></p>",
|
||||
"testString": "assert.deepEqual(convertToRoman(891), 'DCCCXCI');"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>convertToRoman(1000)</code> should return the string <code>M</code></p>",
|
||||
"testString": "assert.deepEqual(convertToRoman(1000), 'M');"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>convertToRoman(1004)</code> should return the string <code>MIV</code></p>",
|
||||
"testString": "assert.deepEqual(convertToRoman(1004), 'MIV');"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>convertToRoman(1006)</code> should return the string <code>MVI</code></p>",
|
||||
"testString": "assert.deepEqual(convertToRoman(1006), 'MVI');"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>convertToRoman(1023)</code> should return the string <code>MXXIII</code></p>",
|
||||
"testString": "assert.deepEqual(convertToRoman(1023), 'MXXIII');"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>convertToRoman(2014)</code> should return the string <code>MMXIV</code></p>",
|
||||
"testString": "assert.deepEqual(convertToRoman(2014), 'MMXIV');"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>convertToRoman(3999)</code> should return the string <code>MMMCMXCIX</code></p>",
|
||||
"testString": "assert.deepEqual(convertToRoman(3999), 'MMMCMXCIX');"
|
||||
}
|
||||
],
|
||||
"description": "<section id=\"description\">\n<p>Convert the given number into a roman numeral.</p>\n<table>\n<thead>\n<tr>\n<th>Roman numerals</th>\n<th>Arabic numerals</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>M</td>\n<td>1000</td>\n</tr>\n<tr>\n<td>CM</td>\n<td>900</td>\n</tr>\n<tr>\n<td>D</td>\n<td>500</td>\n</tr>\n<tr>\n<td>CD</td>\n<td>400</td>\n</tr>\n<tr>\n<td>C</td>\n<td>100</td>\n</tr>\n<tr>\n<td>XC</td>\n<td>90</td>\n</tr>\n<tr>\n<td>L</td>\n<td>50</td>\n</tr>\n<tr>\n<td>XL</td>\n<td>40</td>\n</tr>\n<tr>\n<td>X</td>\n<td>10</td>\n</tr>\n<tr>\n<td>IX</td>\n<td>9</td>\n</tr>\n<tr>\n<td>V</td>\n<td>5</td>\n</tr>\n<tr>\n<td>IV</td>\n<td>4</td>\n</tr>\n<tr>\n<td>I</td>\n<td>1</td>\n</tr>\n</tbody>\n</table>\n<p>All roman numerals answers should be provided in upper-case.</p>\n</section>",
|
||||
"translationPending": false,
|
||||
"block": "javascript-algorithms-and-data-structures-projects",
|
||||
"hasEditableBoundaries": false,
|
||||
"order": 9,
|
||||
"superOrder": 19,
|
||||
"certification": "javascript-algorithms-and-data-structures",
|
||||
"superBlock": "javascript-algorithms-and-data-structures",
|
||||
"challengeOrder": 1,
|
||||
"required": [],
|
||||
"template": "",
|
||||
"time": "50 hours",
|
||||
"helpCategory": "JavaScript",
|
||||
"usesMultifileEditor": false,
|
||||
"disableLoopProtectTests": false,
|
||||
"disableLoopProtectPreview": false
|
||||
},
|
||||
{
|
||||
"id": "aff0395860f5d3034dc0bfc9",
|
||||
"title": "Telephone Number Validator",
|
||||
"challengeType": 5,
|
||||
"forumTopicId": 16090,
|
||||
"dashedName": "telephone-number-validator",
|
||||
"challengeFiles": [
|
||||
{
|
||||
"head": "",
|
||||
"tail": "",
|
||||
"id": "",
|
||||
"editableRegionBoundaries": [],
|
||||
"history": ["script.js"],
|
||||
"name": "script",
|
||||
"ext": "js",
|
||||
"path": "script.js",
|
||||
"fileKey": "scriptjs",
|
||||
"contents": "function telephoneCheck(str) {\n return true;\n}\n\ntelephoneCheck(\"555-555-5555\");",
|
||||
"error": null,
|
||||
"seed": "function telephoneCheck(str) {\n return true;\n}\n\ntelephoneCheck(\"555-555-5555\");"
|
||||
}
|
||||
],
|
||||
"solutions": [
|
||||
[
|
||||
{
|
||||
"head": "",
|
||||
"tail": "",
|
||||
"id": "",
|
||||
"history": ["script.js"],
|
||||
"name": "script",
|
||||
"ext": "js",
|
||||
"path": "script.js",
|
||||
"fileKey": "scriptjs",
|
||||
"contents": "var re = /^([+]?1[\\s]?)?((?:[(](?:[2-9]1[02-9]|[2-9][02-8][0-9])[)][\\s]?)|(?:(?:[2-9]1[02-9]|[2-9][02-8][0-9])[\\s.-]?)){1}([2-9]1[02-9]|[2-9][02-9]1|[2-9][02-9]{2}[\\s.-]?){1}([0-9]{4}){1}$/;\n\nfunction telephoneCheck(str) {\n return re.test(str);\n}\n\ntelephoneCheck(\"555-555-5555\");",
|
||||
"error": null,
|
||||
"seed": "var re = /^([+]?1[\\s]?)?((?:[(](?:[2-9]1[02-9]|[2-9][02-8][0-9])[)][\\s]?)|(?:(?:[2-9]1[02-9]|[2-9][02-8][0-9])[\\s.-]?)){1}([2-9]1[02-9]|[2-9][02-9]1|[2-9][02-9]{2}[\\s.-]?){1}([0-9]{4}){1}$/;\n\nfunction telephoneCheck(str) {\n return re.test(str);\n}\n\ntelephoneCheck(\"555-555-5555\");"
|
||||
}
|
||||
]
|
||||
],
|
||||
"assignments": [],
|
||||
"tests": [
|
||||
{
|
||||
"text": "<p><code>telephoneCheck(\"555-555-5555\")</code> should return a boolean.</p>",
|
||||
"testString": "assert(typeof telephoneCheck('555-555-5555') === 'boolean');"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>telephoneCheck(\"1 555-555-5555\")</code> should return <code>true</code>.</p>",
|
||||
"testString": "assert(telephoneCheck('1 555-555-5555') === true);"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>telephoneCheck(\"1 (555) 555-5555\")</code> should return <code>true</code>.</p>",
|
||||
"testString": "assert(telephoneCheck('1 (555) 555-5555') === true);"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>telephoneCheck(\"5555555555\")</code> should return <code>true</code>.</p>",
|
||||
"testString": "assert(telephoneCheck('5555555555') === true);"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>telephoneCheck(\"555-555-5555\")</code> should return <code>true</code>.</p>",
|
||||
"testString": "assert(telephoneCheck('555-555-5555') === true);"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>telephoneCheck(\"(555)555-5555\")</code> should return <code>true</code>.</p>",
|
||||
"testString": "assert(telephoneCheck('(555)555-5555') === true);"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>telephoneCheck(\"1(555)555-5555\")</code> should return <code>true</code>.</p>",
|
||||
"testString": "assert(telephoneCheck('1(555)555-5555') === true);"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>telephoneCheck(\"555-5555\")</code> should return <code>false</code>.</p>",
|
||||
"testString": "assert(telephoneCheck('555-5555') === false);"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>telephoneCheck(\"5555555\")</code> should return <code>false</code>.</p>",
|
||||
"testString": "assert(telephoneCheck('5555555') === false);"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>telephoneCheck(\"1 555)555-5555\")</code> should return <code>false</code>.</p>",
|
||||
"testString": "assert(telephoneCheck('1 555)555-5555') === false);"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>telephoneCheck(\"1 555 555 5555\")</code> should return <code>true</code>.</p>",
|
||||
"testString": "assert(telephoneCheck('1 555 555 5555') === true);"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>telephoneCheck(\"1 456 789 4444\")</code> should return <code>true</code>.</p>",
|
||||
"testString": "assert(telephoneCheck('1 456 789 4444') === true);"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>telephoneCheck(\"123**&!!asdf#\")</code> should return <code>false</code>.</p>",
|
||||
"testString": "assert(telephoneCheck('123**&!!asdf#') === false);"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>telephoneCheck(\"55555555\")</code> should return <code>false</code>.</p>",
|
||||
"testString": "assert(telephoneCheck('55555555') === false);"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>telephoneCheck(\"(6054756961)\")</code> should return <code>false</code>.</p>",
|
||||
"testString": "assert(telephoneCheck('(6054756961)') === false);"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>telephoneCheck(\"2 (757) 622-7382\")</code> should return <code>false</code>.</p>",
|
||||
"testString": "assert(telephoneCheck('2 (757) 622-7382') === false);"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>telephoneCheck(\"0 (757) 622-7382\")</code> should return <code>false</code>.</p>",
|
||||
"testString": "assert(telephoneCheck('0 (757) 622-7382') === false);"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>telephoneCheck(\"-1 (757) 622-7382\")</code> should return <code>false</code>.</p>",
|
||||
"testString": "assert(telephoneCheck('-1 (757) 622-7382') === false);"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>telephoneCheck(\"2 757 622-7382\")</code> should return <code>false</code>.</p>",
|
||||
"testString": "assert(telephoneCheck('2 757 622-7382') === false);"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>telephoneCheck(\"10 (757) 622-7382\")</code> should return <code>false</code>.</p>",
|
||||
"testString": "assert(telephoneCheck('10 (757) 622-7382') === false);"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>telephoneCheck(\"27576227382\")</code> should return <code>false</code>.</p>",
|
||||
"testString": "assert(telephoneCheck('27576227382') === false);"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>telephoneCheck(\"(275)76227382\")</code> should return <code>false</code>.</p>",
|
||||
"testString": "assert(telephoneCheck('(275)76227382') === false);"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>telephoneCheck(\"2(757)6227382\")</code> should return <code>false</code>.</p>",
|
||||
"testString": "assert(telephoneCheck('2(757)6227382') === false);"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>telephoneCheck(\"2(757)622-7382\")</code> should return <code>false</code>.</p>",
|
||||
"testString": "assert(telephoneCheck('2(757)622-7382') === false);"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>telephoneCheck(\"555)-555-5555\")</code> should return <code>false</code>.</p>",
|
||||
"testString": "assert(telephoneCheck('555)-555-5555') === false);"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>telephoneCheck(\"(555-555-5555\")</code> should return <code>false</code>.</p>",
|
||||
"testString": "assert(telephoneCheck('(555-555-5555') === false);"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>telephoneCheck(\"(555)5(55?)-5555\")</code> should return <code>false</code>.</p>",
|
||||
"testString": "assert(telephoneCheck('(555)5(55?)-5555') === false);"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>telephoneCheck(\"55 55-55-555-5\")</code> should return <code>false</code>.</p>",
|
||||
"testString": "assert(telephoneCheck('55 55-55-555-5') === false);"
|
||||
},
|
||||
{
|
||||
"text": "<p><code>telephoneCheck(\"11 555-555-5555\")</code> should return <code>false</code>.</p>",
|
||||
"testString": "assert(telephoneCheck('11 555-555-5555') === false);"
|
||||
}
|
||||
],
|
||||
"description": "<section id=\"description\">\n<p>Return <code>true</code> if the passed string looks like a valid US phone number.</p>\n<p>The user may fill out the form field any way they choose as long as it has the format of a valid US number. The following are examples of valid formats for US numbers (refer to the tests below for other variants):</p>\n<blockquote>555-555-5555<br>(555)555-5555<br>(555) 555-5555<br>555 555 5555<br>5555555555<br>1 555 555 5555</blockquote>\n<p>For this challenge you will be presented with a string such as <code>800-692-7753</code> or <code>8oo-six427676;laskdjf</code>. Your job is to validate or reject the US phone number based on any combination of the formats provided above. The area code is required. If the country code is provided, you must confirm that the country code is <code>1</code>. Return <code>true</code> if the string is a valid US phone number; otherwise return <code>false</code>.</p>\n</section>",
|
||||
"translationPending": false,
|
||||
"block": "javascript-algorithms-and-data-structures-projects",
|
||||
"hasEditableBoundaries": false,
|
||||
"order": 9,
|
||||
"superOrder": 19,
|
||||
"certification": "javascript-algorithms-and-data-structures",
|
||||
"superBlock": "javascript-algorithms-and-data-structures",
|
||||
"challengeOrder": 3,
|
||||
"required": [],
|
||||
"template": "",
|
||||
"time": "50 hours",
|
||||
"helpCategory": "JavaScript",
|
||||
"usesMultifileEditor": false,
|
||||
"disableLoopProtectTests": false,
|
||||
"disableLoopProtectPreview": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"content": "<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\" /><title>Cafe Menu</title></head><body><main><h1>CAMPER CAFE</h1><p>Est. 2020</p><section><h2>Coffee</h2></section></main></body></html>"
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
[
|
||||
{
|
||||
"questions": [
|
||||
{
|
||||
"distractors": ["<p>Wrong 1</p>", "<p>Wrong 2</p>", "<p>Wrong 3</p>"],
|
||||
"text": "<p>What does the audio say?</p>",
|
||||
"answer": "<p>Correct answer</p>",
|
||||
"audioData": {
|
||||
"audio": {
|
||||
"filename": "test-audio.mp3",
|
||||
"startTimestamp": 0,
|
||||
"finishTimestamp": 2
|
||||
},
|
||||
"transcript": [
|
||||
{
|
||||
"character": "Speaker",
|
||||
"text": "Hello world"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
[
|
||||
{
|
||||
"questions": [
|
||||
{ "distractors": ["Wrong 1", "Wrong 2", "<pre><code>const x = 1;</code></pre>"], "text": "Question 1", "answer": "<pre><code>console.log('ok')</code></pre> Correct answer" },
|
||||
{ "distractors": ["Wrong 1", "Wrong 2", "Wrong 3"], "text": "Question 2", "answer": "Correct answer" },
|
||||
{ "distractors": ["Wrong 1", "Wrong 2", "Wrong 3"], "text": "Question 3", "answer": "Correct answer" },
|
||||
{ "distractors": ["Wrong 1", "Wrong 2", "Wrong 3"], "text": "Question 4", "answer": "Correct answer" },
|
||||
{ "distractors": ["Wrong 1", "Wrong 2", "Wrong 3"], "text": "Question 5", "answer": "Correct answer" },
|
||||
{ "distractors": ["Wrong 1", "Wrong 2", "Wrong 3"], "text": "Question 6", "answer": "Correct answer" },
|
||||
{ "distractors": ["Wrong 1", "Wrong 2", "Wrong 3"], "text": "Question 7", "answer": "Correct answer" },
|
||||
{ "distractors": ["Wrong 1", "Wrong 2", "Wrong 3"], "text": "Question 8", "answer": "Correct answer" },
|
||||
{ "distractors": ["Wrong 1", "Wrong 2", "Wrong 3"], "text": "Question 9", "answer": "Correct answer" },
|
||||
{ "distractors": ["Wrong 1", "Wrong 2", "Wrong 3"], "text": "Question 10", "answer": "Correct answer" },
|
||||
{ "distractors": ["Wrong 1", "Wrong 2", "Wrong 3"], "text": "Question 11", "answer": "Correct answer" },
|
||||
{ "distractors": ["Wrong 1", "Wrong 2", "Wrong 3"], "text": "Question 12", "answer": "Correct answer" },
|
||||
{ "distractors": ["Wrong 1", "Wrong 2", "Wrong 3"], "text": "Question 13", "answer": "Correct answer" },
|
||||
{ "distractors": ["Wrong 1", "Wrong 2", "Wrong 3"], "text": "Question 14", "answer": "Correct answer" },
|
||||
{ "distractors": ["Wrong 1", "Wrong 2", "Wrong 3"], "text": "Question 15", "answer": "Correct answer" },
|
||||
{ "distractors": ["Wrong 1", "Wrong 2", "Wrong 3"], "text": "Question 16", "answer": "Correct answer" },
|
||||
{ "distractors": ["Wrong 1", "Wrong 2", "Wrong 3"], "text": "Question 17", "answer": "Correct answer" },
|
||||
{ "distractors": ["Wrong 1", "Wrong 2", "Wrong 3"], "text": "Question 18", "answer": "Correct answer" },
|
||||
{ "distractors": ["Wrong 1", "Wrong 2", "Wrong 3"], "text": "Question 19", "answer": "Correct answer" },
|
||||
{ "distractors": ["Wrong 1", "Wrong 2", "Wrong 3"], "text": "Question 20", "answer": "Correct answer" }
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"id": "bd7158d8c442eddfaeb5bd18",
|
||||
"htmlFile": {
|
||||
"contents": "<head><link rel=\"stylesheet\" href=\"styles.css\"></head><body><main id=\"main\"><div id=\"title\">D</div><div id=\"img-div\"><img id=\"image\" src=\"\" alt=\"D\" ><div id=\"img-caption\">s</div></div><div id=\"tribute-info\">s</div><a id=\"tribute-link\" href=\"\" target=\"_blank\">L</a> Tribute page text</main></body></html>",
|
||||
"key": "indexhtml",
|
||||
"ext": "html",
|
||||
"name": "index",
|
||||
"history": ["index.html"]
|
||||
},
|
||||
"cssFile": {
|
||||
"contents": "#image{max-width:100%;height:auto;display:block;margin:0 auto;}",
|
||||
"key": "stylescss",
|
||||
"ext": "css",
|
||||
"name": "styles",
|
||||
"history": ["styles.css"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import translations from '../client/i18n/locales/english/translations.json';
|
||||
|
||||
const checkFlashMessageVisibility = async (page: Page, translation: string) => {
|
||||
const flashMessage = page.getByText(translation);
|
||||
await expect(flashMessage).toBeVisible();
|
||||
const closeButton = page.getByRole('button', { name: 'close' });
|
||||
await closeButton.click();
|
||||
await expect(flashMessage).not.toBeVisible();
|
||||
};
|
||||
|
||||
test.describe('Flash Message component E2E test', () => {
|
||||
test('Flash Message Visibility for Night Mode Toggle', async ({ page }) => {
|
||||
await page.goto('/settings');
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: translations.buttons.menu, exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: translations.settings.labels['night-mode'],
|
||||
exact: true
|
||||
})
|
||||
.click();
|
||||
await checkFlashMessageVisibility(
|
||||
page,
|
||||
translations.flash['updated-themes']
|
||||
);
|
||||
});
|
||||
|
||||
test('Flash Message Visibility for Sound Mode Toggle', async ({ page }) => {
|
||||
await page.goto('/settings');
|
||||
|
||||
await page
|
||||
.getByLabel(translations.settings.labels['sound-mode'])
|
||||
.getByRole('button', { name: translations.buttons.on })
|
||||
.click();
|
||||
await checkFlashMessageVisibility(
|
||||
page,
|
||||
translations.flash['updated-sound']
|
||||
);
|
||||
});
|
||||
|
||||
test('should be visible when a network error occurs', async ({ page }) => {
|
||||
await page.route('*/**/user/session-user', async route => {
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ user: {}, result: '' })
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await checkFlashMessageVisibility(
|
||||
page,
|
||||
translations.flash['user-fetch-error']
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import translations from '../client/i18n/locales/english/translations.json';
|
||||
|
||||
test.describe('Test form with only solution link', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(
|
||||
'learn/front-end-development-libraries/front-end-development-libraries-projects/build-a-random-quote-machine'
|
||||
);
|
||||
});
|
||||
|
||||
test('The form is visible and has only solution link input', async ({
|
||||
page
|
||||
}) => {
|
||||
// The solution form should be present and visible
|
||||
const solutionForm = page.getByTestId('form-helper-form');
|
||||
await expect(solutionForm).toBeVisible();
|
||||
|
||||
// The form submit button should be disabled as the form is not filled
|
||||
const solutionFormButton = solutionForm.getByRole('button', {
|
||||
name: `${translations.learn['i-completed']}`
|
||||
});
|
||||
await expect(solutionFormButton).toBeVisible();
|
||||
await expect(solutionFormButton).toBeDisabled();
|
||||
|
||||
const solutionLinkInputLabel = solutionForm.getByTestId(
|
||||
'solution-control-label'
|
||||
);
|
||||
await expect(solutionLinkInputLabel).toBeVisible();
|
||||
await expect(solutionLinkInputLabel).toHaveText(
|
||||
translations.learn['solution-link']
|
||||
);
|
||||
|
||||
const solutionLinkInput = solutionForm.getByTestId('solution-form-control');
|
||||
await expect(solutionLinkInput).toBeVisible();
|
||||
|
||||
const githubLinkInputLabel = solutionForm.getByTestId(
|
||||
'githubLink-control-label'
|
||||
);
|
||||
await expect(githubLinkInputLabel).not.toBeVisible();
|
||||
|
||||
// The form submit button should be enabled as the form is now filled
|
||||
await solutionLinkInput.fill('test-input');
|
||||
await expect(solutionFormButton).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Test form with solution link and github link', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(
|
||||
'learn/quality-assurance/quality-assurance-projects/personal-library'
|
||||
);
|
||||
});
|
||||
|
||||
test('The form is visible and has solution link and github link input', async ({
|
||||
page
|
||||
}) => {
|
||||
// The solution form should be present and visible
|
||||
const solutionForm = page.getByTestId('form-helper-form');
|
||||
await expect(solutionForm).toBeVisible();
|
||||
|
||||
// The form submit button should be disabled as the form is not filled
|
||||
const solutionFormButton = solutionForm.getByRole('button', {
|
||||
name: `${translations.learn['i-completed']}`
|
||||
});
|
||||
await expect(solutionFormButton).toBeVisible();
|
||||
await expect(solutionFormButton).toBeDisabled();
|
||||
|
||||
const solutionLinkInputLabel = solutionForm.getByTestId(
|
||||
'solution-control-label'
|
||||
);
|
||||
await expect(solutionLinkInputLabel).toBeVisible();
|
||||
await expect(solutionLinkInputLabel).toHaveText(
|
||||
translations.learn['solution-link']
|
||||
);
|
||||
|
||||
const solutionLinkInput = solutionForm.getByTestId('solution-form-control');
|
||||
await expect(solutionLinkInput).toBeVisible();
|
||||
|
||||
const githubLinkInputLabel = solutionForm.getByTestId(
|
||||
'githubLink-control-label'
|
||||
);
|
||||
await expect(githubLinkInputLabel).toBeVisible();
|
||||
await expect(githubLinkInputLabel).toHaveText(
|
||||
translations.learn['source-code-link']
|
||||
);
|
||||
|
||||
const githubLinkInput = solutionForm.getByTestId('githubLink-form-control');
|
||||
await expect(githubLinkInput).toBeVisible();
|
||||
|
||||
// The form submit button should be enabled as the form is now filled
|
||||
await solutionLinkInput.fill('test-input');
|
||||
await expect(solutionFormButton).toBeEnabled();
|
||||
|
||||
// The form submit button should be enabled as the GitHub link is now filled
|
||||
await solutionLinkInput.fill('');
|
||||
await expect(solutionFormButton).toBeDisabled();
|
||||
await githubLinkInput.fill('test-input');
|
||||
await expect(solutionFormButton).toBeEnabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const requiredCerts = [
|
||||
{
|
||||
text: 'Responsive Web Design Certification',
|
||||
slug: '/learn/responsive-web-design-v9/'
|
||||
},
|
||||
{
|
||||
text: 'JavaScript Certification',
|
||||
slug: '/learn/javascript-v9/'
|
||||
},
|
||||
{
|
||||
text: 'Front-End Development Libraries Certification',
|
||||
slug: '/learn/front-end-development-libraries-v9/'
|
||||
},
|
||||
{
|
||||
text: 'Python Certification',
|
||||
slug: '/learn/python-v9/'
|
||||
},
|
||||
{
|
||||
text: 'Relational Databases Certification',
|
||||
slug: '/learn/relational-databases-v9/'
|
||||
},
|
||||
{
|
||||
text: 'Back-End Development and APIs Certification',
|
||||
slug: '/learn/back-end-development-and-apis-v9/'
|
||||
}
|
||||
];
|
||||
|
||||
test.describe('Full-Stack Developer V9 superBlock page', () => {
|
||||
test('lists and links to requirements', async ({ page }) => {
|
||||
await page.goto('/learn/full-stack-developer-v9/');
|
||||
|
||||
const reqList = page.locator('.requirement-list');
|
||||
await expect(reqList).toBeVisible();
|
||||
|
||||
const reqLinks = reqList.locator('.chapter.requirement .chapter-button');
|
||||
await expect(reqLinks).toHaveCount(requiredCerts.length);
|
||||
|
||||
for (let i = 0; i < requiredCerts.length; i++) {
|
||||
const reqLink = reqLinks.nth(i);
|
||||
await expect(reqLink).toBeVisible();
|
||||
await expect(reqLink).toContainText(requiredCerts[i].text);
|
||||
await expect(reqLink).toHaveAttribute('href', requiredCerts[i].slug);
|
||||
}
|
||||
});
|
||||
|
||||
if (process.env.SHOW_UPCOMING_CHANGES === 'true') {
|
||||
test('shows the exam', async ({ page }) => {
|
||||
await page.goto('/learn/full-stack-developer-v9/');
|
||||
const examChapterButton = page.locator('.chapter .chapter-button', {
|
||||
hasText: /certified full-stack developer exam/i
|
||||
});
|
||||
|
||||
await expect(examChapterButton).toBeVisible();
|
||||
await expect(examChapterButton).toHaveAttribute(
|
||||
'href',
|
||||
'/learn/full-stack-developer-v9/exam-certified-full-stack-developer/exam-certified-full-stack-developer'
|
||||
);
|
||||
});
|
||||
} else {
|
||||
test('shows the exam module and coming soon text', async ({ page }) => {
|
||||
await page.goto('/learn/full-stack-developer-v9/');
|
||||
const examChapterButton = page.locator('.chapter .chapter-button', {
|
||||
hasText: /certified full-stack developer exam/i
|
||||
});
|
||||
await expect(examChapterButton).toBeVisible();
|
||||
|
||||
const examModuleButton = page.locator('.module-button', {
|
||||
hasText: /certified full-stack developer exam/i
|
||||
});
|
||||
await examModuleButton.click();
|
||||
|
||||
const moduleIntro = page.locator('.module-intro');
|
||||
await expect(moduleIntro).toBeVisible();
|
||||
await expect(moduleIntro).toContainText('Coming Late 2026');
|
||||
await expect(moduleIntro).toContainText(
|
||||
'This exam will test what you have learned throughout the previous six certifications.'
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
import { test as setup } from '@playwright/test';
|
||||
|
||||
setup.describe('certifieduser', () => {
|
||||
setup.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
setup.beforeAll(() => {
|
||||
execSync('node ../tools/scripts/seed/seed-demo-user --certified-user ');
|
||||
});
|
||||
|
||||
setup('can sign in', async ({ request }) => {
|
||||
await request.get(process.env.API_LOCATION + '/signin');
|
||||
await request.storageState({
|
||||
path: 'playwright/.auth/certified-user.json'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
setup.describe('developmentuser', () => {
|
||||
// We need to make sure the development user does not have any cookies from the certified user.
|
||||
// As the certified user now has the default storage state.
|
||||
setup.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
// We can only sign in as a single user (one with email: 'foo@bar.com'), so
|
||||
// changing users means changing the record with that email in the database.
|
||||
setup.beforeAll(() => {
|
||||
execSync('node ../tools/scripts/seed/seed-demo-user');
|
||||
});
|
||||
|
||||
setup.afterAll(() => {
|
||||
execSync('node ../tools/scripts/seed/seed-demo-user --certified-user');
|
||||
});
|
||||
|
||||
setup('can sign in', async ({ request }) => {
|
||||
await request.get(process.env.API_LOCATION + '/signin');
|
||||
await request.storageState({
|
||||
path: 'playwright/.auth/development-user.json'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,304 @@
|
||||
import { execSync } from 'child_process';
|
||||
import { test, expect } from '@playwright/test';
|
||||
import translations from '../client/i18n/locales/english/translations.json';
|
||||
import {
|
||||
availableLangs,
|
||||
hiddenLangs,
|
||||
LangNames
|
||||
} from '@freecodecamp/shared/config/i18n';
|
||||
import links from '../client/i18n/locales/english/links.json';
|
||||
|
||||
const headerComponentElements = {
|
||||
skipContent: 'header-skip-content',
|
||||
examNav: 'header-exam-nav',
|
||||
examNavLogo: 'header-exam-nav-microsoft-logo',
|
||||
universalNav: 'header-universal-nav',
|
||||
universalNavLogo: 'header-universal-nav-logo',
|
||||
toggleLangButton: 'header-toggle-lang-button',
|
||||
languageList: 'header-lang-list',
|
||||
languageButton: 'header-lang-list-option',
|
||||
menuButton: 'header-menu-button',
|
||||
menu: 'header-menu',
|
||||
signInButton: 'sign-in-button'
|
||||
} as const;
|
||||
|
||||
const examUrl =
|
||||
'/learn/foundational-c-sharp-with-microsoft/foundational-c-sharp-with-microsoft-certification-exam/foundational-c-sharp-with-microsoft-certification-exam';
|
||||
|
||||
test.describe('Header', () => {
|
||||
test.use({ storageState: 'playwright/.auth/development-user.json' });
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test.beforeAll(() => {
|
||||
execSync('node ../tools/scripts/seed/seed-demo-user');
|
||||
});
|
||||
|
||||
test.afterAll(() => {
|
||||
execSync('node ../tools/scripts/seed/seed-demo-user --certified-user');
|
||||
});
|
||||
|
||||
test('Has link for skip content', async ({ page }) => {
|
||||
const skipContent = page.getByTestId(headerComponentElements.skipContent);
|
||||
await expect(skipContent).toBeVisible();
|
||||
await expect(skipContent).toHaveAttribute('href', '#content-start');
|
||||
});
|
||||
|
||||
test('Renders universal nav by default', async ({ page }) => {
|
||||
const universalNavigation = page.getByTestId(
|
||||
headerComponentElements.universalNav
|
||||
);
|
||||
const universalNavigationLogo = page.getByTestId(
|
||||
headerComponentElements.universalNavLogo
|
||||
);
|
||||
await expect(universalNavigation).toBeVisible();
|
||||
await expect(universalNavigationLogo).toBeVisible();
|
||||
await expect(universalNavigationLogo).toHaveAttribute('href', '/learn');
|
||||
});
|
||||
|
||||
test('Should display search in header on desktop and in menu on mobile', async ({
|
||||
page,
|
||||
isMobile
|
||||
}) => {
|
||||
const searchInput = page.getByLabel(translations.search.label);
|
||||
const menuButton = page.getByTestId(headerComponentElements.menuButton);
|
||||
|
||||
if (isMobile) {
|
||||
await expect(searchInput).toBeHidden();
|
||||
await menuButton.click();
|
||||
await expect(searchInput).toBeVisible();
|
||||
} else {
|
||||
await expect(searchInput).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('Clicking the "Change Language" button should open the language list', async ({
|
||||
page
|
||||
}) => {
|
||||
const toggleLangButton = page.getByTestId(
|
||||
headerComponentElements.toggleLangButton
|
||||
);
|
||||
await expect(toggleLangButton).toBeVisible();
|
||||
await toggleLangButton.click();
|
||||
const langList = page.getByTestId(headerComponentElements.languageList);
|
||||
await expect(langList).toBeVisible();
|
||||
});
|
||||
|
||||
test('The language list should contain a button for each available language', async ({
|
||||
page
|
||||
}) => {
|
||||
const locales = availableLangs.client.filter(
|
||||
lang => !hiddenLangs.includes(lang)
|
||||
);
|
||||
|
||||
const toggleLangButton = page.getByTestId(
|
||||
headerComponentElements.toggleLangButton
|
||||
);
|
||||
await expect(toggleLangButton).toBeVisible();
|
||||
await toggleLangButton.click();
|
||||
const langList = page.getByTestId(headerComponentElements.languageList);
|
||||
await expect(langList).toBeVisible();
|
||||
|
||||
const langButtons = page.getByTestId(
|
||||
headerComponentElements.languageButton
|
||||
);
|
||||
await expect(langButtons).toHaveCount(locales.length);
|
||||
|
||||
for (let i = 0; i < locales.length; i++) {
|
||||
const btn = langButtons.nth(i);
|
||||
await expect(btn).toContainText(LangNames[locales[i]]);
|
||||
}
|
||||
});
|
||||
|
||||
test('Clicking the menu button should open the menu', async ({ page }) => {
|
||||
const menuButton = page.getByTestId(headerComponentElements.menuButton);
|
||||
const menu = page.getByTestId(headerComponentElements.menu);
|
||||
await expect(menuButton).toBeVisible();
|
||||
await menuButton.click();
|
||||
await expect(menu).toBeVisible();
|
||||
});
|
||||
|
||||
test('Focusing on a menu item, and pressing Esc should close the menu and focus on the menu button', async ({
|
||||
page
|
||||
}) => {
|
||||
const menuButton = page.getByTestId(headerComponentElements.menuButton);
|
||||
const menu = page.getByTestId(headerComponentElements.menu);
|
||||
|
||||
await expect(menuButton).toBeVisible();
|
||||
await menuButton.click();
|
||||
|
||||
const link = menu.getByRole('link', { name: translations.buttons.donate });
|
||||
await link.focus();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(menu).toBeHidden();
|
||||
|
||||
await expect(menuButton).toBeFocused();
|
||||
});
|
||||
|
||||
test('The menu should contain links to: donate, curriculum, catalog, forum, news, radio, contribute, and podcast', async ({
|
||||
page
|
||||
}) => {
|
||||
const menuButton = page.getByTestId(headerComponentElements.menuButton);
|
||||
const menu = page.getByTestId(headerComponentElements.menu);
|
||||
await expect(menuButton).toBeVisible();
|
||||
await menuButton.click();
|
||||
await expect(menu).toBeVisible();
|
||||
|
||||
const menuLinks = [
|
||||
{ name: translations.buttons.profile, href: '/developmentuser' },
|
||||
{
|
||||
name: translations.buttons.donate,
|
||||
href: '/donate'
|
||||
},
|
||||
{
|
||||
name: translations.buttons.curriculum,
|
||||
href: '/learn'
|
||||
},
|
||||
{
|
||||
name: translations.buttons.catalog,
|
||||
href: '/catalog'
|
||||
},
|
||||
{
|
||||
name: translations.buttons.forum,
|
||||
href: links.nav.forum
|
||||
},
|
||||
{
|
||||
name: translations.buttons.news,
|
||||
href: links.nav.news
|
||||
},
|
||||
{
|
||||
name: translations.buttons.radio,
|
||||
href: process.env.RADIO_LOCATION || 'https://coderadio.freecodecamp.org'
|
||||
},
|
||||
{
|
||||
name: translations.buttons.contribute,
|
||||
href: links.nav.contribute
|
||||
},
|
||||
{
|
||||
name: translations.buttons.podcast,
|
||||
href: links.nav.podcast
|
||||
}
|
||||
];
|
||||
|
||||
for (const menuLink of menuLinks) {
|
||||
const link = menu.getByRole('link', { name: menuLink.name });
|
||||
await expect(link).toBeVisible();
|
||||
await expect(link).toHaveAttribute('href', menuLink.href);
|
||||
}
|
||||
});
|
||||
|
||||
test('The menu should be able to change the theme', async ({ page }) => {
|
||||
const menuButton = page.getByTestId(headerComponentElements.menuButton);
|
||||
const menu = page.getByTestId(headerComponentElements.menu);
|
||||
await menuButton.click();
|
||||
await expect(menu).toBeVisible();
|
||||
|
||||
const themeButton = menu.getByRole('button', {
|
||||
name: 'Night Mode'
|
||||
});
|
||||
await themeButton.click();
|
||||
|
||||
await expect(page.locator('body')).toHaveClass('dark-palette');
|
||||
|
||||
await expect(page.getByTestId('flash-message')).toContainText(
|
||||
/We have updated your theme/
|
||||
);
|
||||
await expect(menu).toBeHidden();
|
||||
|
||||
await menuButton.click();
|
||||
await expect(menu).toBeVisible();
|
||||
|
||||
await themeButton.click();
|
||||
await expect(page.locator('body')).toHaveClass('light-palette');
|
||||
});
|
||||
|
||||
test('The header should contain an avatar', async ({ page }) => {
|
||||
const avatarLink = page.getByRole('link', { name: 'Profile' });
|
||||
await expect(avatarLink).toBeVisible();
|
||||
await expect(avatarLink).toHaveAttribute('href', '/developmentuser');
|
||||
|
||||
const avatar = avatarLink.getByRole('img', {
|
||||
name: 'Default Avatar',
|
||||
includeHidden: true // the svg is aria-hidden
|
||||
});
|
||||
await expect(avatar).toBeVisible();
|
||||
});
|
||||
|
||||
test('The Avatar should be less or equal to 26px', async ({ page }) => {
|
||||
const avatar = page
|
||||
.getByRole('link', { name: 'Profile' })
|
||||
.getByRole('img', {
|
||||
name: 'Default Avatar',
|
||||
includeHidden: true // the svg is aria-hidden
|
||||
});
|
||||
|
||||
await expect(avatar).toBeVisible();
|
||||
const avatarSize = await avatar.boundingBox();
|
||||
expect(avatarSize?.width).toBeLessThanOrEqual(26);
|
||||
expect(avatarSize?.height).toBeLessThanOrEqual(26);
|
||||
});
|
||||
|
||||
test('The Sign In button should redirect to api/signin', async ({
|
||||
browser
|
||||
}) => {
|
||||
// Sign out user in order to test Sign In button
|
||||
const context = await browser.newContext({
|
||||
storageState: { cookies: [], origins: [] }
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto('/');
|
||||
|
||||
const signInButton = page
|
||||
.locator('header')
|
||||
.getByRole('link', { name: translations.buttons['sign-in'] });
|
||||
|
||||
const apiLocation = process.env.API_LOCATION || 'http://localhost:3000';
|
||||
|
||||
await expect(signInButton).toHaveAttribute('href', `${apiLocation}/signin`);
|
||||
});
|
||||
|
||||
test('When the user is signed out, only certain elements should be visible', async ({
|
||||
browser
|
||||
}) => {
|
||||
const context = await browser.newContext({
|
||||
storageState: { cookies: [], origins: [] }
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto('/');
|
||||
const signInButton = page
|
||||
.getByTestId(headerComponentElements.signInButton)
|
||||
.nth(0);
|
||||
await expect(signInButton).toBeVisible();
|
||||
|
||||
const avatar = page
|
||||
.getByRole('link', { name: 'Profile' })
|
||||
.getByRole('img', {
|
||||
name: 'Default Avatar',
|
||||
includeHidden: true // the svg is aria-hidden
|
||||
});
|
||||
await expect(avatar).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Exam Header', () => {
|
||||
test('Renders exam nav for Foundational C# with Microsoft exam', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(examUrl);
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: translations.buttons['click-start-exam']
|
||||
})
|
||||
.click();
|
||||
await expect(page).toHaveURL(examUrl);
|
||||
await expect(
|
||||
page.getByTestId(headerComponentElements.examNav)
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId(headerComponentElements.examNavLogo)
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import translations from '../client/i18n/locales/english/translations.json';
|
||||
|
||||
test.describe('Mobile help-button tests for a page with three links (hint, help and video)', () => {
|
||||
test.use({
|
||||
viewport: { width: 393, height: 851 },
|
||||
isMobile: true
|
||||
});
|
||||
test('should render the button, menu and the three links when video is available', async ({
|
||||
page,
|
||||
isMobile
|
||||
}) => {
|
||||
test.skip(!isMobile, 'Help dropdown only available on mobile');
|
||||
// visit the page with the video link
|
||||
await page.goto(
|
||||
'/learn/responsive-web-design/basic-html-and-html5/say-hello-to-html-elements'
|
||||
);
|
||||
//The button is visible
|
||||
const helpButton = page.getByTestId('get-help-dropdown');
|
||||
await expect(helpButton).toBeVisible();
|
||||
//The button is clickable
|
||||
await helpButton.click();
|
||||
//The menu items are visible
|
||||
await expect(page.getByTestId('get-hint')).toBeVisible();
|
||||
await expect(page.getByTestId('ask-for-help')).toBeVisible();
|
||||
await expect(page.getByTestId('watch-a-video')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Mobile help-button tests for a page with two links when video is not available', () => {
|
||||
test.use({
|
||||
viewport: { width: 393, height: 851 },
|
||||
isMobile: true
|
||||
});
|
||||
test('should render the button, menu and the two links when video is not available', async ({
|
||||
page,
|
||||
isMobile
|
||||
}) => {
|
||||
test.skip(!isMobile, 'Help dropdown only available on mobile');
|
||||
|
||||
await page.goto(
|
||||
'/learn/front-end-development-libraries/bootstrap/apply-the-default-bootstrap-button-style'
|
||||
);
|
||||
//The button is visible
|
||||
const helpButton = page.getByTestId('get-help-dropdown');
|
||||
await expect(helpButton).toBeVisible();
|
||||
//The button is clickable
|
||||
await helpButton.click();
|
||||
//The menu items are visible
|
||||
await expect(page.getByTestId('get-hint')).toBeVisible();
|
||||
await expect(page.getByTestId('ask-for-help')).toBeVisible();
|
||||
//The video link is hidden
|
||||
await expect(page.getByTestId('watch-a-video')).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Mobile help-button tests for a page with a reset and help button', () => {
|
||||
// Test the lower jaw on mobile viewport only
|
||||
test.use({
|
||||
viewport: { width: 393, height: 851 },
|
||||
isMobile: true
|
||||
});
|
||||
test('should not be present before the user checks their code three times', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(
|
||||
'learn/2022/responsive-web-design/learn-html-by-building-a-cat-photo-app/step-8'
|
||||
);
|
||||
await expect(page.getByRole('button', { name: 'Help' })).toBeHidden();
|
||||
});
|
||||
|
||||
test('should be present after the user checks their code three times', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(
|
||||
'learn/2022/responsive-web-design/learn-html-by-building-a-cat-photo-app/step-3'
|
||||
);
|
||||
const checkButton = page.getByTestId('lowerJaw-check-button');
|
||||
await checkButton.click();
|
||||
await checkButton.click();
|
||||
await checkButton.click();
|
||||
const helpButton = page.getByRole('button', { name: 'Help' });
|
||||
await expect(helpButton).toBeVisible();
|
||||
const helpIconGroup = helpButton.getByRole('group', {
|
||||
includeHidden: false
|
||||
});
|
||||
await expect(helpIconGroup).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Desktop help-button tests for a page with a reset and help button', () => {
|
||||
test('should always be shown', async ({ page }) => {
|
||||
await page.goto(
|
||||
'learn/2022/responsive-web-design/learn-html-by-building-a-cat-photo-app/step-3'
|
||||
);
|
||||
await expect(
|
||||
page.getByRole('button', { name: translations.buttons.reset })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: translations.buttons.help })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should open help modal with video button when video is available', async ({
|
||||
page
|
||||
}) => {
|
||||
// visit the page with the video link
|
||||
await page.goto(
|
||||
'/learn/responsive-web-design/basic-html-and-html5/say-hello-to-html-elements'
|
||||
);
|
||||
|
||||
// Click the help button in independent lower jaw
|
||||
const helpButton = page.getByRole('button', {
|
||||
name: translations.buttons.help
|
||||
});
|
||||
await expect(helpButton).toBeVisible();
|
||||
await helpButton.click();
|
||||
|
||||
// Help modal should open
|
||||
await expect(
|
||||
page.getByRole('dialog', { name: translations.buttons['get-help'] })
|
||||
).toBeVisible();
|
||||
|
||||
// Video button should be present in the modal
|
||||
await expect(
|
||||
page.getByRole('button', { name: translations.buttons['watch-video'] })
|
||||
).toBeVisible();
|
||||
|
||||
// Other help options should be present
|
||||
await expect(
|
||||
page.getByRole('link', { name: translations.buttons['get-hint'] })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: translations.buttons['create-post'] })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should open help modal without video button when video is not available', async ({
|
||||
page
|
||||
}) => {
|
||||
// visit the page without the video link
|
||||
await page.goto(
|
||||
'/learn/front-end-development-libraries/bootstrap/apply-the-default-bootstrap-button-style'
|
||||
);
|
||||
|
||||
// Click the help button in independent lower jaw
|
||||
const helpButton = page.getByRole('button', {
|
||||
name: translations.buttons.help
|
||||
});
|
||||
await expect(helpButton).toBeVisible();
|
||||
await helpButton.click();
|
||||
|
||||
// Help modal should open
|
||||
await expect(
|
||||
page.getByRole('dialog', { name: translations.buttons['get-help'] })
|
||||
).toBeVisible();
|
||||
|
||||
// Video button should NOT be present in the modal
|
||||
await expect(
|
||||
page.getByRole('button', { name: translations.buttons['watch-video'] })
|
||||
).toBeHidden();
|
||||
|
||||
// Other help options should be present
|
||||
await expect(
|
||||
page.getByRole('link', { name: translations.buttons['get-hint'] })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: translations.buttons['create-post'] })
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import translations from '../client/i18n/locales/english/translations.json';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(
|
||||
'/learn/foundational-c-sharp-with-microsoft/write-your-first-code-using-c-sharp/write-your-first-c-sharp-code'
|
||||
);
|
||||
});
|
||||
|
||||
test.describe('Help Modal component', () => {
|
||||
test('renders the modal correctly', async ({ page }) => {
|
||||
await page
|
||||
.getByRole('button', { name: translations.buttons['ask-for-help'] })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('dialog', { name: translations.buttons['get-help'] })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(
|
||||
`If you've already tried the Read-Search-Ask method, then you can ask for help on the freeCodeCamp forum.`
|
||||
)
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(
|
||||
`Before making a new post please check if your question has already been answered on the forum.`
|
||||
)
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: translations.buttons['create-post'] })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: translations.buttons.cancel })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: translations.buttons.close })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should disable the submit button if the checkboxes are not checked', async ({
|
||||
page
|
||||
}) => {
|
||||
await page
|
||||
.getByRole('button', { name: translations.buttons['ask-for-help'] })
|
||||
.click();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: translations.buttons['create-post']
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('dialog', { name: translations.buttons['get-help'] })
|
||||
).toBeVisible();
|
||||
|
||||
const rsaCheckbox = page.getByRole('checkbox', {
|
||||
name: 'I have tried the Read-Search-Ask method'
|
||||
});
|
||||
|
||||
const similarQuestionsCheckbox = page.getByRole('checkbox', {
|
||||
name: 'I have searched for similar questions that have already been answered on the forum'
|
||||
});
|
||||
|
||||
const descriptionInput = page.getByRole('textbox', {
|
||||
name: translations['forum-help']['whats-happening']
|
||||
});
|
||||
|
||||
const submitButton = page.getByRole('button', {
|
||||
name: translations.buttons['submit']
|
||||
});
|
||||
|
||||
await descriptionInput.fill(
|
||||
'Example text with a 100 characters to validate if the rules applied to block users from spamming help forum are working.'
|
||||
);
|
||||
|
||||
await expect(submitButton).toBeDisabled();
|
||||
|
||||
await rsaCheckbox.check();
|
||||
await similarQuestionsCheckbox.uncheck();
|
||||
|
||||
await expect(submitButton).toBeDisabled();
|
||||
|
||||
await rsaCheckbox.uncheck();
|
||||
await similarQuestionsCheckbox.check();
|
||||
|
||||
await expect(submitButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test('should disable the submit button if the description contains less than 50 characters', async ({
|
||||
page
|
||||
}) => {
|
||||
await page
|
||||
.getByRole('button', { name: translations.buttons['ask-for-help'] })
|
||||
.click();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: translations.buttons['create-post']
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('dialog', { name: translations.buttons['get-help'] })
|
||||
).toBeVisible();
|
||||
|
||||
const rsaCheckbox = page.getByRole('checkbox', {
|
||||
name: 'I have tried the Read-Search-Ask method'
|
||||
});
|
||||
|
||||
const similarQuestionsCheckbox = page.getByRole('checkbox', {
|
||||
name: 'I have searched for similar questions that have already been answered on the forum'
|
||||
});
|
||||
|
||||
const descriptionInput = page.getByRole('textbox', {
|
||||
name: translations['forum-help']['whats-happening']
|
||||
});
|
||||
|
||||
const submitButton = page.getByRole('button', {
|
||||
name: translations.buttons['submit']
|
||||
});
|
||||
|
||||
await rsaCheckbox.check();
|
||||
await similarQuestionsCheckbox.check();
|
||||
await descriptionInput.fill('Example text');
|
||||
|
||||
await expect(submitButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test('should ask the user to fill in the help form and create a forum page', async ({
|
||||
context,
|
||||
page
|
||||
}) => {
|
||||
await page
|
||||
.getByRole('button', { name: translations.buttons['ask-for-help'] })
|
||||
.click();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: translations.buttons['create-post']
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('dialog', { name: translations.buttons['get-help'] })
|
||||
).toBeVisible();
|
||||
|
||||
const rsaCheckbox = page.getByRole('checkbox', {
|
||||
name: 'I have tried the Read-Search-Ask method'
|
||||
});
|
||||
|
||||
const similarQuestionsCheckbox = page.getByRole('checkbox', {
|
||||
name: 'I have searched for similar questions that have already been answered on the forum'
|
||||
});
|
||||
|
||||
const descriptionInput = page.getByRole('textbox', {
|
||||
name: translations['forum-help']['whats-happening']
|
||||
});
|
||||
|
||||
const submitButton = page.getByRole('button', {
|
||||
name: translations.buttons['submit']
|
||||
});
|
||||
|
||||
await expect(rsaCheckbox).toBeVisible();
|
||||
await expect(similarQuestionsCheckbox).toBeVisible();
|
||||
await expect(descriptionInput).toBeVisible();
|
||||
|
||||
await rsaCheckbox.check();
|
||||
await similarQuestionsCheckbox.check();
|
||||
await descriptionInput.fill(
|
||||
'Example text with a 100 characters to validate if the rules applied to block users from spamming help forum are working.'
|
||||
);
|
||||
|
||||
await expect(submitButton).toBeEnabled();
|
||||
await submitButton.click();
|
||||
|
||||
const newPagePromise = context.waitForEvent('page');
|
||||
|
||||
const newPage = await newPagePromise;
|
||||
await newPage.waitForLoadState();
|
||||
|
||||
await expect(newPage).toHaveURL(/.*forum\.freecodecamp.org.*/);
|
||||
});
|
||||
|
||||
test('Cancel button closes the modal', async ({ page }) => {
|
||||
await page
|
||||
.getByRole('button', { name: translations.buttons['ask-for-help'] })
|
||||
.click();
|
||||
|
||||
const dialog = page.getByRole('dialog', {
|
||||
name: translations.buttons['get-help']
|
||||
});
|
||||
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: translations.buttons.cancel })
|
||||
.click();
|
||||
|
||||
await expect(dialog).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Close button closes the modal', async ({ page }) => {
|
||||
await page
|
||||
.getByRole('button', { name: translations.buttons['ask-for-help'] })
|
||||
.click();
|
||||
|
||||
const dialog = page.getByRole('dialog', {
|
||||
name: translations.buttons['get-help']
|
||||
});
|
||||
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: translations.buttons.close })
|
||||
.click();
|
||||
|
||||
await expect(dialog).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Read-Search-Ask link', async ({ page }) => {
|
||||
await page
|
||||
.getByRole('button', { name: translations.buttons['ask-for-help'] })
|
||||
.click();
|
||||
const link = page.getByRole('link', { name: 'Read-Search-Ask' });
|
||||
await expect(link).toHaveAttribute(
|
||||
'href',
|
||||
'https://forum.freecodecamp.org/t/19514'
|
||||
);
|
||||
await expect(link).toHaveAttribute('target', '_blank');
|
||||
await expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
|
||||
test('Already been answered link', async ({ page }) => {
|
||||
await page
|
||||
.getByRole('button', { name: translations.buttons['ask-for-help'] })
|
||||
.click();
|
||||
const link = page.getByRole('link', {
|
||||
name: 'already been answered on the forum'
|
||||
});
|
||||
await expect(link).toHaveAttribute(
|
||||
'href',
|
||||
'https://forum.freecodecamp.org/search?q=Write%20Your%20First%20Code%20Using%20C%23%20-%20Write%20Your%20First%20C%23%20Code%20in%3Atitle'
|
||||
);
|
||||
await expect(link).toHaveAttribute('target', '_blank');
|
||||
await expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
import translations from '../client/i18n/locales/english/translations.json';
|
||||
import { authedRequest } from './utils/request';
|
||||
import { clearEditor, getEditors } from './utils/editor';
|
||||
import { alertToBeVisible } from './utils/alerts';
|
||||
|
||||
const links = {
|
||||
basicJS1:
|
||||
'/learn/javascript-algorithms-and-data-structures/basic-javascript/comment-your-javascript-code',
|
||||
basicJS2:
|
||||
'/learn/javascript-algorithms-and-data-structures/basic-javascript/declare-javascript-variables',
|
||||
frontEnd1:
|
||||
'/learn/front-end-development-libraries/front-end-development-libraries-projects/build-a-random-quote-machine',
|
||||
frontEnd2:
|
||||
'/learn/front-end-development-libraries/front-end-development-libraries-projects/build-a-markdown-previewer',
|
||||
backEnd1:
|
||||
'/learn/back-end-development-and-apis/back-end-development-and-apis-projects/timestamp-microservice',
|
||||
backEnd2:
|
||||
'learn/back-end-development-and-apis/back-end-development-and-apis-projects/request-header-parser-microservice',
|
||||
video1:
|
||||
'/learn/python-for-everybody/python-for-everybody/introduction-why-program',
|
||||
video2:
|
||||
'/learn/python-for-everybody/python-for-everybody/introduction-hardware-architecture',
|
||||
multipleChoiceQuestion:
|
||||
'/learn/a2-english-for-developers/learn-greetings-in-your-first-day-at-the-office/task-7',
|
||||
assignment:
|
||||
'/learn/responsive-web-design-v9/review-semantic-html/review-semantic-html',
|
||||
multifileLab:
|
||||
'/learn/responsive-web-design-v9/lab-debug-camperbots-profile-page/lab-debug-camperbots-profile-page'
|
||||
};
|
||||
|
||||
const titles = {
|
||||
basicJS1: /Comment Your JavaScript Code/,
|
||||
basicJS2: /Declare JavaScript Variables/,
|
||||
frontEnd2: /Build a Markdown Previewer/,
|
||||
backEnd2: /Request Header Parser Microservice/,
|
||||
video2: /Introduction: Hardware Architecture/
|
||||
};
|
||||
type PageId = keyof typeof titles;
|
||||
|
||||
const multifileLabSolution = `<h1>Hello from Camperbot!</h1>
|
||||
|
||||
<h2>About</h2>
|
||||
|
||||
<p>My name is Camperbot and I love learning new things.</p>
|
||||
|
||||
<h3>Background and Interests</h3>
|
||||
<p>I enjoy solving puzzles.</p>`;
|
||||
|
||||
// The hotkeys are attached to specific elements, so we need to wait for the
|
||||
// wrapper to be focused before we can test the hotkeys.
|
||||
const waitUntilListening = async (page: Page) =>
|
||||
await expect(page.locator('#editor-layout')).toBeFocused();
|
||||
|
||||
// This is a hack to work around the fact that the page isn't always hydrated
|
||||
// with the new content when the URL changes.
|
||||
const waitUntilHydrated = async (page: Page, pageId: PageId) => {
|
||||
await page.waitForURL(links[pageId]);
|
||||
await expect(page).toHaveTitle(titles[pageId]);
|
||||
await waitUntilListening(page);
|
||||
};
|
||||
|
||||
const completeMultifileLabWithHotkey = async ({
|
||||
browserName,
|
||||
hotkey,
|
||||
page
|
||||
}: {
|
||||
browserName: string;
|
||||
hotkey: 'Control+Enter' | 'Meta+Enter';
|
||||
page: Page;
|
||||
}) => {
|
||||
await page.goto(links.multifileLab);
|
||||
|
||||
const editor = getEditors(page);
|
||||
await editor.focus();
|
||||
await expect(editor).toBeFocused();
|
||||
await clearEditor({ page, browserName });
|
||||
await editor.fill(multifileLabSolution);
|
||||
|
||||
await page.keyboard.press(hotkey);
|
||||
|
||||
await expect(
|
||||
page.getByTestId('independentLowerJaw-submit-button')
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||||
};
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await authedRequest({
|
||||
request,
|
||||
endpoint: 'update-my-keyboard-shortcuts',
|
||||
method: 'put',
|
||||
data: {
|
||||
keyboardShortcuts: false
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/settings');
|
||||
const keyboardShortcutGroup = page.getByRole('group', {
|
||||
name: translations.settings.labels['keyboard-shortcuts']
|
||||
});
|
||||
await keyboardShortcutGroup
|
||||
.getByRole('button', { name: translations.buttons.on, exact: true })
|
||||
.click();
|
||||
// wait for the client to register the change:
|
||||
await alertToBeVisible(page, translations.flash['keyboard-shortcut-updated']);
|
||||
});
|
||||
|
||||
test.afterEach(
|
||||
async ({ request }) =>
|
||||
await authedRequest({
|
||||
request,
|
||||
method: 'put',
|
||||
endpoint: 'update-my-keyboard-shortcuts',
|
||||
data: {
|
||||
keyboardShortcuts: false
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// TODO: handle keyboard shortcuts on mobile
|
||||
test.skip(({ isMobile }) => isMobile, 'Only test on desktop');
|
||||
|
||||
test('User can use shortcuts in and around the editor', async ({ page }) => {
|
||||
await page.goto(links.basicJS1);
|
||||
|
||||
await expect(getEditors(page)).toBeFocused();
|
||||
await getEditors(page).press('Escape');
|
||||
await expect(getEditors(page)).not.toBeFocused();
|
||||
|
||||
await page.keyboard.press('n');
|
||||
await waitUntilHydrated(page, 'basicJS2');
|
||||
|
||||
await page.keyboard.press('p');
|
||||
await waitUntilHydrated(page, 'basicJS1');
|
||||
|
||||
await page.keyboard.press('e');
|
||||
await expect(getEditors(page)).toBeFocused();
|
||||
|
||||
await getEditors(page).press('Escape');
|
||||
await page.keyboard.press('r');
|
||||
await expect(page.locator('.instructions-panel')).toBeFocused();
|
||||
});
|
||||
|
||||
test('User can use shortcuts to navigate between frontend projects', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(links.frontEnd1);
|
||||
await waitUntilListening(page);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await page.keyboard.press('n');
|
||||
await waitUntilHydrated(page, 'frontEnd2');
|
||||
await page.keyboard.press('p');
|
||||
await page.waitForURL(links.frontEnd1);
|
||||
});
|
||||
|
||||
test('User can use shortcuts to navigate between backend projects', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(links.backEnd1);
|
||||
await waitUntilListening(page);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await page.keyboard.press('n');
|
||||
await waitUntilHydrated(page, 'backEnd2');
|
||||
await page.keyboard.press('p');
|
||||
await page.waitForURL(links.backEnd1);
|
||||
});
|
||||
|
||||
test('User can use shortcuts to navigate between video-based challenges', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(links.video1);
|
||||
await waitUntilListening(page);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await page.keyboard.press('n');
|
||||
await waitUntilHydrated(page, 'video2');
|
||||
await page.keyboard.press('p');
|
||||
await page.waitForURL(links.video1);
|
||||
});
|
||||
|
||||
test('User can use Ctrl+Enter to submit their answer in a multiple-choice question challenge', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(links.multipleChoiceQuestion);
|
||||
|
||||
// Wait for page load
|
||||
await expect(page.getByRole('heading', { name: 'Task 7' })).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Control+Enter');
|
||||
|
||||
await expect(
|
||||
page.getByText('You have unanswered questions and/or incorrect answers.')
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('User can use Cmd+Enter to submit their answer in a multiple-choice question challenge', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(links.multipleChoiceQuestion);
|
||||
|
||||
// Wait for page load
|
||||
await expect(page.getByRole('heading', { name: 'Task 7' })).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Meta+Enter');
|
||||
|
||||
await expect(
|
||||
page.getByText('You have unanswered questions and/or incorrect answers.')
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('User can use Ctrl+Enter to submit their answer in an assignment-type challenge', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(links.assignment);
|
||||
|
||||
// Wait for page load
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Semantic HTML Review' })
|
||||
).toBeVisible();
|
||||
|
||||
// Check the assignment checkbox
|
||||
await page.getByRole('checkbox').check();
|
||||
|
||||
await page.keyboard.press('Control+Enter');
|
||||
|
||||
// Completion modal shows up
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
});
|
||||
|
||||
test('User can use Cmd+Enter to submit their answer in an assignment-type challenge', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(links.assignment);
|
||||
|
||||
// Wait for page load
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Semantic HTML Review' })
|
||||
).toBeVisible();
|
||||
|
||||
// Check the assignment checkbox
|
||||
await page.getByRole('checkbox').check();
|
||||
|
||||
await page.keyboard.press('Meta+Enter');
|
||||
|
||||
// Completion modal shows up
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Ctrl+Enter should not open completion modal in multifile editor (uses lower jaw)', async ({
|
||||
page,
|
||||
browserName
|
||||
}) => {
|
||||
await completeMultifileLabWithHotkey({
|
||||
browserName,
|
||||
hotkey: 'Control+Enter',
|
||||
page
|
||||
});
|
||||
});
|
||||
|
||||
test('Cmd+Enter should not open completion modal in multifile editor (uses lower jaw)', async ({
|
||||
page,
|
||||
browserName
|
||||
}) => {
|
||||
await completeMultifileLabWithHotkey({
|
||||
browserName,
|
||||
hotkey: 'Meta+Enter',
|
||||
page
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import translations from '../client/i18n/locales/english/translations.json';
|
||||
import { focusEditor } from './utils/editor';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(
|
||||
'/learn/javascript-algorithms-and-data-structures-v8/build-a-cash-register-project/build-a-cash-register'
|
||||
);
|
||||
});
|
||||
|
||||
test.describe('Preventing Script From Displaying in Preview', () => {
|
||||
// this test is for inserting problematic <style> tag and checking if the preview is empty
|
||||
|
||||
test('should render an empty preview after inserting CSS', async ({
|
||||
page,
|
||||
isMobile
|
||||
}) => {
|
||||
await focusEditor({ page, isMobile });
|
||||
|
||||
// the pain in the butt <style> tag with display: block rule into the editor
|
||||
const styleTag = `
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: block;
|
||||
}
|
||||
</style>`;
|
||||
await page.keyboard.insertText(styleTag);
|
||||
|
||||
if (isMobile) {
|
||||
await page
|
||||
.getByRole('tab', { name: translations.learn['editor-tabs'].preview })
|
||||
.click();
|
||||
}
|
||||
|
||||
// Checks that the iframe preview is empty
|
||||
const frame = page.frameLocator('#fcc-main-frame').first();
|
||||
|
||||
// Make sure there are no visible elements inside the iframe's body
|
||||
await expect(frame.locator('body')).toBeEmpty();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Picture input field', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/certifieduser');
|
||||
|
||||
await page.getByRole('button', { name: 'Edit my profile' }).click();
|
||||
});
|
||||
|
||||
test('Should be possible to type', async ({ page }) => {
|
||||
const pictureInput = page.getByLabel('Picture');
|
||||
await pictureInput.fill('');
|
||||
await pictureInput.fill('twaha');
|
||||
await expect(pictureInput).toHaveAttribute('value', 'twaha');
|
||||
});
|
||||
|
||||
test('Show an error message if an incorrect url was submitted', async ({
|
||||
page
|
||||
}) => {
|
||||
const pictureInput = page.getByLabel('Picture');
|
||||
await pictureInput.fill('');
|
||||
await pictureInput.fill(
|
||||
'https://cdn.freecodecamp.org/platform/universal/camper-image-placeholder'
|
||||
);
|
||||
await expect(
|
||||
page.getByText('URL must link directly to an image file')
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('Can submit a correct URL', async ({ page }) => {
|
||||
const pictureInput = page.getByLabel('Picture');
|
||||
await pictureInput.fill('');
|
||||
await pictureInput.fill(
|
||||
'https://cdn.freecodecamp.org/platform/universal/camper-image-placeholder.png'
|
||||
);
|
||||
|
||||
const form = page.getByTestId('camper-identity');
|
||||
const saveButton = form.getByRole('button', { name: 'Save' });
|
||||
await expect(saveButton).toBeEnabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { clearEditor, focusEditor, getEditors } from './utils/editor';
|
||||
|
||||
const workshopChallengeUrl =
|
||||
'/learn/responsive-web-design-v9/workshop-cafe-menu/step-2';
|
||||
const penguinChallengeUrl =
|
||||
'/learn/2022/responsive-web-design/learn-css-transforms-by-building-a-penguin/step-4';
|
||||
|
||||
const workshopPassingSolution = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Cafe Menu</title>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>CAMPER CAFE</h1>
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
test.use({
|
||||
viewport: { width: 1080, height: 720 }
|
||||
});
|
||||
|
||||
test('Editor is focused on load', async ({ page }) => {
|
||||
await page.goto(workshopChallengeUrl);
|
||||
|
||||
await expect(getEditors(page)).toBeFocused();
|
||||
});
|
||||
|
||||
test('Clicking "Check Your Code" reveals failing feedback', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(workshopChallengeUrl);
|
||||
|
||||
await page.getByTestId('independentLowerJaw-check-button').click();
|
||||
|
||||
await expect(
|
||||
page.getByTestId('independentLowerJaw-failing-hint')
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('Reset button opens and closes the reset modal', async ({ page }) => {
|
||||
await page.goto(workshopChallengeUrl);
|
||||
|
||||
await page.getByTestId('independentLowerJaw-reset-button').click();
|
||||
|
||||
const resetModal = page.getByRole('dialog', { name: 'Reset this lesson?' });
|
||||
await expect(resetModal).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: /close/i }).click();
|
||||
await expect(resetModal).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Checks hotkeys when instruction is focused', async ({
|
||||
page,
|
||||
browserName
|
||||
}) => {
|
||||
await page.goto(workshopChallengeUrl);
|
||||
|
||||
const editor = getEditors(page);
|
||||
const description = page.locator('#description');
|
||||
await focusEditor({ page, isMobile: false });
|
||||
await clearEditor({ page, browserName, isMobile: false });
|
||||
await editor.fill(workshopPassingSolution);
|
||||
await description.click();
|
||||
|
||||
if (browserName === 'webkit') {
|
||||
await page.keyboard.press('Meta+Enter');
|
||||
} else {
|
||||
await page.keyboard.press('Control+Enter');
|
||||
}
|
||||
|
||||
await expect(
|
||||
page.getByTestId('independentLowerJaw-check-button')
|
||||
).not.toBeFocused();
|
||||
});
|
||||
|
||||
test('Hint text should not contain placeholders `fcc-expected`', async ({
|
||||
page,
|
||||
browserName,
|
||||
isMobile
|
||||
}) => {
|
||||
await page.goto(penguinChallengeUrl);
|
||||
|
||||
const editor = getEditors(page);
|
||||
await focusEditor({ page, isMobile });
|
||||
await clearEditor({ page, browserName, isMobile });
|
||||
await editor.fill(
|
||||
'body{background:linear-gradient(45deg, rgb(118, 201, 255), rgb(247, 255, 222));margin:0;padding:0;width:5%;height:100vh}'
|
||||
);
|
||||
|
||||
await page.getByTestId('independentLowerJaw-check-button').click();
|
||||
|
||||
const hintDescription = page.getByTestId('independentLowerJaw-failing-hint');
|
||||
await expect(hintDescription).toContainText(
|
||||
'You should give body a width of 100%, but found 5%',
|
||||
{ useInnerText: true }
|
||||
);
|
||||
});
|
||||
|
||||
test.describe('Unauthenticated user', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test('Focuses on the sign in button when unauthenticated user completes a step', async ({
|
||||
page,
|
||||
browserName,
|
||||
isMobile
|
||||
}) => {
|
||||
await page.goto(workshopChallengeUrl);
|
||||
|
||||
const editor = getEditors(page);
|
||||
await focusEditor({ page, isMobile });
|
||||
await clearEditor({ page, browserName, isMobile });
|
||||
await editor.fill(workshopPassingSolution);
|
||||
|
||||
await page.getByTestId('independentLowerJaw-check-button').click();
|
||||
|
||||
const signInLink = page.getByTestId('independentLowerJaw-signin-link');
|
||||
await expect(
|
||||
page.getByTestId('independentLowerJaw-submit-button')
|
||||
).toBeVisible();
|
||||
await expect(signInLink).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId('independentLowerJaw-check-button')
|
||||
).toHaveCount(0);
|
||||
await expect(signInLink).toBeFocused();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Authenticated user', () => {
|
||||
test.use({ storageState: 'playwright/.auth/certified-user.json' });
|
||||
|
||||
test('Focuses on the submit button when authenticated user completes a step', async ({
|
||||
page,
|
||||
browserName,
|
||||
isMobile
|
||||
}) => {
|
||||
await page.goto(workshopChallengeUrl);
|
||||
|
||||
const editor = getEditors(page);
|
||||
await focusEditor({ page, isMobile });
|
||||
await clearEditor({ page, browserName, isMobile });
|
||||
await editor.fill(workshopPassingSolution);
|
||||
|
||||
await page.getByTestId('independentLowerJaw-check-button').click();
|
||||
|
||||
const submitButton = page.getByTestId('independentLowerJaw-submit-button');
|
||||
await expect(submitButton).toBeVisible();
|
||||
await expect(submitButton).toContainText('Submit and continue');
|
||||
await expect(submitButton).toBeFocused();
|
||||
await expect(
|
||||
page.getByTestId('independentLowerJaw-signin-link')
|
||||
).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const html = {
|
||||
challengePath:
|
||||
'/learn/responsive-web-design-v9/lecture-what-is-css/what-are-some-default-browser-styles-applied-to-html',
|
||||
challengeTitle: 'What Are Some Default Browser Styles Applied to HTML?',
|
||||
paragraphOneText:
|
||||
'When you start working with HTML and CSS, you\'ll notice that some styles are applied to your web pages even before you write any CSS. These styles are called "default browser styles" or "user-agent styles".',
|
||||
codeSnippet: '<h1>Heading 1</h1>'
|
||||
};
|
||||
|
||||
const js = {
|
||||
challengePath:
|
||||
'/learn/javascript-v9/lecture-introduction-to-javascript/what-is-a-data-type'
|
||||
};
|
||||
|
||||
test.describe('Interactive Editor', () => {
|
||||
// skipping because I don't know of a page with paragraph nodules that doesn't also have an interactive editor toggle
|
||||
test.skip('should render paragraph nodules as text and not show the interactive editor toggle', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(html.challengePath);
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: html.challengeTitle })
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page.getByText(html.paragraphOneText)).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: /interactive editor/i })
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('should toggle between interactive editor and static code view when Interactive Editor button is clicked', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(html.challengePath);
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: html.challengeTitle })
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(html.paragraphOneText)).toBeVisible();
|
||||
|
||||
// Initially, interactive editor should be hidden, static code view should be visible
|
||||
await expect(page.getByTestId('sp-interactive-editor')).not.toBeVisible();
|
||||
await expect(
|
||||
page.locator('pre code').filter({ hasText: html.codeSnippet })
|
||||
).toHaveCount(1);
|
||||
|
||||
// Click the toggle button
|
||||
const toggleButton = page.getByRole('button', {
|
||||
name: /interactive editor/i
|
||||
});
|
||||
await toggleButton.click();
|
||||
|
||||
// Interactive editor should be visible, static code view hidden
|
||||
await expect(
|
||||
page.getByTestId('sp-interactive-editor').first()
|
||||
).toBeVisible();
|
||||
await expect(page.locator('pre code')).not.toBeVisible();
|
||||
|
||||
// Click the toggle button again
|
||||
await toggleButton.click();
|
||||
|
||||
// Interactive editor should be hidden, static code view visible again
|
||||
await expect(page.getByTestId('sp-interactive-editor')).not.toBeVisible();
|
||||
await expect(
|
||||
page.locator('pre code').filter({ hasText: html.codeSnippet })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should persist the preference for showing the interactive editor', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(html.challengePath);
|
||||
|
||||
// Initially, the interactive editor should be hidden
|
||||
await expect(page.getByTestId('sp-interactive-editor')).not.toBeVisible();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: /interactive editor/i
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByTestId('sp-interactive-editor').first()
|
||||
).toBeVisible();
|
||||
|
||||
// Reload the page and check that the interactive editor is still shown
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.getByTestId('sp-interactive-editor').first()
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should hide the preview panel in JS-only interactive editors and just show the console', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(js.challengePath);
|
||||
|
||||
// Click the toggle button to show interactive editor
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: /interactive editor/i
|
||||
})
|
||||
.click();
|
||||
|
||||
// Check that the consoles are visible
|
||||
const consoles = page.getByTestId('sp-console');
|
||||
await expect(consoles).toHaveCount(4);
|
||||
for (const console of await consoles.all()) {
|
||||
await expect(console).toBeVisible();
|
||||
}
|
||||
|
||||
// Check that the preview is not visible
|
||||
const previews = page.getByTestId('sp-preview');
|
||||
await expect(previews).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { execSync } from 'child_process';
|
||||
import { test, expect } from '@playwright/test';
|
||||
import translations from '../client/i18n/locales/english/translations.json';
|
||||
|
||||
const settingsPageElement = {
|
||||
githubInput: 'internet-github-input',
|
||||
githubCheckmark: 'internet-github-check',
|
||||
linkedinCheckmark: 'internet-linkedin-check',
|
||||
twitterCheckmark: 'internet-twitter-check',
|
||||
blueskyCheckmark: 'internet-bluesky-check',
|
||||
personalWebsiteCheckmark: 'internet-website-check',
|
||||
flashMessageAlert: 'flash-message',
|
||||
internetPresenceForm: 'internet-presence'
|
||||
} as const;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Reset input values
|
||||
execSync('node ../tools/scripts/seed/seed-demo-user --certified-user');
|
||||
|
||||
await page.goto('/certifieduser');
|
||||
|
||||
await page.getByRole('button', { name: 'Edit my profile' }).click();
|
||||
});
|
||||
|
||||
test.describe('Your Internet Presence', () => {
|
||||
test.skip(({ browserName }) => browserName === 'webkit', 'flaky on Safari');
|
||||
test('should display the section with save button being disabled', async ({
|
||||
page
|
||||
}) => {
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
level: 2,
|
||||
name: translations.settings.headings.internet
|
||||
})
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByTestId(settingsPageElement.internetPresenceForm)
|
||||
.getByRole('button', { name: translations.buttons.save })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
const socials = [
|
||||
{
|
||||
name: 'github',
|
||||
url: 'https://github.com/certified-user',
|
||||
label: 'GitHub',
|
||||
checkTestId: settingsPageElement.githubCheckmark
|
||||
},
|
||||
{
|
||||
name: 'linkedin',
|
||||
url: 'https://www.linkedin.com/in/certified-user',
|
||||
label: 'LinkedIn',
|
||||
checkTestId: settingsPageElement.linkedinCheckmark
|
||||
},
|
||||
{
|
||||
name: 'twitter',
|
||||
url: 'https://x.com/certified-user',
|
||||
label: 'X',
|
||||
checkTestId: settingsPageElement.twitterCheckmark
|
||||
},
|
||||
{
|
||||
name: 'bluesky',
|
||||
url: 'https://bsky.app/profile/certified-user.bsky.social',
|
||||
label: 'Bluesky',
|
||||
checkTestId: settingsPageElement.blueskyCheckmark
|
||||
},
|
||||
{
|
||||
name: 'website',
|
||||
url: 'https://certified-user.com',
|
||||
label: translations.settings.labels.personal,
|
||||
checkTestId: settingsPageElement.personalWebsiteCheckmark
|
||||
}
|
||||
];
|
||||
|
||||
socials.forEach(social => {
|
||||
test(`should hide ${social.name} checkmark by default`, async ({
|
||||
page
|
||||
}) => {
|
||||
await expect(page.getByTestId(social.checkTestId)).toBeHidden();
|
||||
});
|
||||
|
||||
test(`should update ${social.name} URL`, async ({ page }) => {
|
||||
const socialInput = page.getByRole('textbox', { name: social.label });
|
||||
await expect(socialInput).toBeVisible();
|
||||
await socialInput.fill(social.url);
|
||||
const socialCheckmark = page.getByTestId(social.checkTestId);
|
||||
await expect(socialCheckmark).toBeVisible();
|
||||
|
||||
const saveButton = page
|
||||
.getByTestId(settingsPageElement.internetPresenceForm)
|
||||
.getByRole('button', { name: translations.buttons.save });
|
||||
|
||||
await expect(saveButton).toBeVisible();
|
||||
await saveButton.click();
|
||||
await expect(page.getByRole('alert').first()).toContainText(
|
||||
'We have updated your social links'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Certification intro page', () => {
|
||||
// Use the development user so the certification blocks are incomplete and
|
||||
// therefore rendered expanded. The fully certified user has completed every
|
||||
// challenge, which collapses the blocks and hides their descriptions.
|
||||
test.use({ storageState: 'playwright/.auth/development-user.json' });
|
||||
|
||||
test.beforeAll(() => {
|
||||
execSync('node ../tools/scripts/seed/seed-demo-user');
|
||||
});
|
||||
|
||||
test.afterAll(() => {
|
||||
execSync('node ../tools/scripts/seed/seed-demo-user --certified-user');
|
||||
});
|
||||
|
||||
test('Should render and toggle correctly', async ({ page }) => {
|
||||
const firstBlockToggle = page.getByRole('button', {
|
||||
name: /^Learn HTML by Building a Cat Photo App/
|
||||
});
|
||||
|
||||
const firstBlockText = page.getByText(
|
||||
'HTML tags give a webpage its structure. You can use HTML tags to add photos, buttons, and other elements to your webpage.'
|
||||
);
|
||||
|
||||
const secondBlockText = page.getByText(
|
||||
'CSS tells the browser how to display your webpage. You can use CSS to set the color, font, size, and other aspects of HTML elements.'
|
||||
);
|
||||
|
||||
const superBlockText = page.getByText(
|
||||
"this Responsive Web Design Certification, you'll learn the languages that developers use to build webpages"
|
||||
);
|
||||
|
||||
await page.goto('/learn/2022/responsive-web-design');
|
||||
|
||||
await expect(page).toHaveTitle(
|
||||
'Legacy Responsive Web Design V8 | freeCodeCamp.org'
|
||||
);
|
||||
await expect(superBlockText).toBeVisible();
|
||||
await expect(firstBlockText).toBeVisible();
|
||||
await expect(secondBlockText).not.toBeVisible();
|
||||
|
||||
await firstBlockToggle.click();
|
||||
await expect(firstBlockText).not.toBeVisible();
|
||||
|
||||
await firstBlockToggle.click();
|
||||
await expect(firstBlockText).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
import { expect, Page, test } from '@playwright/test';
|
||||
import intro from '../client/i18n/locales/english/intro.json';
|
||||
import translations from '../client/i18n/locales/english/translations.json';
|
||||
import { SuperBlocks } from '@freecodecamp/shared/config/curriculum';
|
||||
import { addGrowthbookCookie } from './utils/add-growthbook-cookie';
|
||||
|
||||
const landingPageElements = {
|
||||
heading: 'landing-header',
|
||||
certifications: 'certifications',
|
||||
curriculumBtns: 'curriculum-map-button',
|
||||
testimonials: 'testimonial-card',
|
||||
landingPageImage: 'landing-page-figure',
|
||||
faq: 'landing-page-faq',
|
||||
jobs: 'More than <strong>100,000</strong> freeCodeCamp.org graduates have gotten <strong>jobs</strong> at tech companies including:',
|
||||
googleCTA: 'landing-google-cta',
|
||||
moreWaysCTA: 'landing-more-ways-cta',
|
||||
landingTopCta: 'landing-top-big-cta'
|
||||
} as const;
|
||||
|
||||
const nonArchivedSuperBlocks = [
|
||||
intro[SuperBlocks.RespWebDesignV9].title,
|
||||
intro[SuperBlocks.JsV9].title,
|
||||
intro[SuperBlocks.FrontEndDevLibsV9].title,
|
||||
intro[SuperBlocks.PythonV9].title,
|
||||
intro[SuperBlocks.RelationalDbV9].title,
|
||||
intro[SuperBlocks.BackEndDevApisV9].title,
|
||||
intro[SuperBlocks.FullStackDeveloperV9].title,
|
||||
intro[SuperBlocks.A2English].title,
|
||||
intro[SuperBlocks.B1English].title,
|
||||
intro[SuperBlocks.A1Spanish].title,
|
||||
intro[SuperBlocks.A1Chinese].title,
|
||||
intro[SuperBlocks.TheOdinProject].title,
|
||||
intro[SuperBlocks.CodingInterviewPrep].title,
|
||||
intro[SuperBlocks.ProjectEuler].title,
|
||||
intro[SuperBlocks.RosettaCode].title,
|
||||
intro[SuperBlocks.FoundationalCSharp].title
|
||||
];
|
||||
|
||||
async function goToLandingPage(page: Page) {
|
||||
await page.goto('/');
|
||||
}
|
||||
|
||||
test.describe('Main CTA - Variation A', () => {
|
||||
test.beforeEach(async ({ context, page }) => {
|
||||
await addGrowthbookCookie({ context, variation: 'A' });
|
||||
await goToLandingPage(page);
|
||||
});
|
||||
test('Five main CTAs render correctly', async ({ page }) => {
|
||||
const landingTopCta = page.getByTestId(landingPageElements.landingTopCta);
|
||||
const googleCTA = page.getByTestId(landingPageElements.googleCTA);
|
||||
const moreWaysCTA = page.getByTestId(landingPageElements.moreWaysCTA);
|
||||
const ctas = page.getByRole('link', {
|
||||
name: translations.buttons['logged-in-cta-btn']
|
||||
});
|
||||
const benefitsCtas = page.getByRole('link', {
|
||||
name: translations.landing.benefits.cta
|
||||
});
|
||||
await expect(benefitsCtas).toHaveCount(1);
|
||||
await expect(landingTopCta).toHaveText(
|
||||
translations.buttons['logged-in-cta-btn']
|
||||
);
|
||||
await expect(ctas).toHaveCount(4);
|
||||
for (const cta of await ctas.all()) {
|
||||
await expect(cta).toBeVisible();
|
||||
}
|
||||
await expect(googleCTA).toBeHidden();
|
||||
await expect(moreWaysCTA).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Main CTA - Variation B', () => {
|
||||
test.beforeEach(async ({ context, page }) => {
|
||||
await addGrowthbookCookie({ context, variation: 'B' });
|
||||
await goToLandingPage(page);
|
||||
});
|
||||
test('Four main and two stacked CTAs render correctly', async ({ page }) => {
|
||||
const landingTopCta = page.getByTestId(landingPageElements.landingTopCta);
|
||||
const googleCTA = page.getByTestId(landingPageElements.googleCTA);
|
||||
const moreWaysCTA = page.getByTestId(landingPageElements.moreWaysCTA);
|
||||
const ctas = page.getByRole('link', {
|
||||
name: translations.buttons['logged-in-cta-btn']
|
||||
});
|
||||
const benefitsCtas = page.getByRole('link', {
|
||||
name: translations.landing.benefits.cta
|
||||
});
|
||||
await expect(benefitsCtas).toHaveCount(1);
|
||||
await expect(landingTopCta).toBeHidden();
|
||||
await expect(ctas).toHaveCount(3);
|
||||
for (const cta of await ctas.all()) {
|
||||
await expect(cta).toBeVisible();
|
||||
}
|
||||
await expect(googleCTA).toHaveText(
|
||||
translations.buttons['sign-in-with-google']
|
||||
);
|
||||
await expect(moreWaysCTA).toHaveText(
|
||||
translations.buttons['more-ways-to-sign-in']
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Landing Page', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await goToLandingPage(page);
|
||||
});
|
||||
|
||||
test('Main heading copy renders correctly', async ({ page }) => {
|
||||
const bigHeading = page.getByTestId('big-heading-1-b');
|
||||
await expect(bigHeading).toHaveText(
|
||||
translations.landing['big-heading-1-b']
|
||||
);
|
||||
});
|
||||
|
||||
test('Supporting copy renders correctly', async ({ page }) => {
|
||||
const bigHeading = page.getByTestId('advance-career');
|
||||
await expect(bigHeading).toHaveText(translations.landing['advance-career']);
|
||||
});
|
||||
|
||||
test('Logo row copy renders correctly', async ({ page }) => {
|
||||
const landingH2Heading = page.getByTestId('graduates-work');
|
||||
await expect(landingH2Heading).toHaveText(
|
||||
translations.landing['graduates-work'].replace(/<\/?strong>/g, '')
|
||||
);
|
||||
});
|
||||
|
||||
test('The component Why learn with freeCodeCamp renders correctly', async ({
|
||||
context,
|
||||
page
|
||||
}) => {
|
||||
await addGrowthbookCookie({ context, variation: 'C' });
|
||||
await goToLandingPage(page);
|
||||
const h2Element = page.locator(
|
||||
`h2:has-text("${translations.landing.benefits['heading']}")`
|
||||
);
|
||||
|
||||
await expect(h2Element).toBeVisible();
|
||||
});
|
||||
|
||||
test('Hero image should have an alt', async ({ isMobile, page }) => {
|
||||
const campersImage = page.getByAltText(
|
||||
translations.landing['hero-img-alt']
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
await expect(campersImage).toBeHidden();
|
||||
} else {
|
||||
await expect(campersImage).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('Has 5 brand logos', async ({ page }) => {
|
||||
const logos = page.getByTestId('brand-logo-container').locator('svg');
|
||||
await expect(logos).toHaveCount(5);
|
||||
for (const logo of await logos.all()) {
|
||||
await expect(logo).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('The campers landing page figure is visible on desktop and hidden on mobile view', async ({
|
||||
page,
|
||||
isMobile
|
||||
}) => {
|
||||
const landingPageImage = page.getByTestId('landing-page-figure');
|
||||
if (isMobile) {
|
||||
await expect(landingPageImage).toBeHidden();
|
||||
} else {
|
||||
await expect(landingPageImage).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('Testimonial section has a header', async ({ page }) => {
|
||||
const testimonialsHeader = page.getByTestId('testimonials-section-header');
|
||||
await expect(testimonialsHeader).toHaveText(
|
||||
translations.landing.testimonials['heading']
|
||||
);
|
||||
});
|
||||
|
||||
test('Testimonial endorser people have images, occupation, location and testimony visible', async ({
|
||||
page
|
||||
}) => {
|
||||
const cards = page.getByTestId('testimonial-card');
|
||||
await expect(cards).toHaveCount(3);
|
||||
for (const card of await cards.all()) {
|
||||
await expect(card).toBeVisible();
|
||||
await expect(
|
||||
card.getByTestId('testimonials-endorser-image-container')
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
card.getByTestId('testimonials-endorser-location')
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
card.getByTestId('testimonials-endorser-occupation')
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
card.getByTestId('testimonials-endorser-testimony')
|
||||
).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('Links to all non-archived superblocks in order', async ({ page }) => {
|
||||
const curriculumBtns = page.locator(
|
||||
`[data-testid="${landingPageElements.curriculumBtns}"]`
|
||||
);
|
||||
await expect(curriculumBtns).toHaveCount(nonArchivedSuperBlocks.length);
|
||||
for (let index = 0; index < nonArchivedSuperBlocks.length; index++) {
|
||||
const btn = curriculumBtns.nth(index);
|
||||
const link = btn.getByRole('link', {
|
||||
name: nonArchivedSuperBlocks[index]
|
||||
});
|
||||
await expect(link).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('Links to the archive page', async ({ page }) => {
|
||||
const archiveLink = page.locator('a[href="/learn/archive"]');
|
||||
await expect(archiveLink).toBeVisible();
|
||||
});
|
||||
|
||||
test('Has FAQ section', async ({ page }) => {
|
||||
const faqs = page.getByTestId(landingPageElements.faq);
|
||||
await expect(faqs).toHaveCount(9);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import words from '../client/i18n/locales/english/motivation.json';
|
||||
|
||||
test.describe('Learn - Unauthenticated user', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test('the page should render correctly', async ({ page }) => {
|
||||
await page.goto('/learn');
|
||||
|
||||
await expect(page).toHaveTitle(
|
||||
'Learn to Code — For Free — Coding Courses for Busy People'
|
||||
);
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
level: 1,
|
||||
name: "Welcome to freeCodeCamp's curriculum."
|
||||
})
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'Sign in to save your progress' })
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Learn - Authenticated user)', () => {
|
||||
test('the page should render correctly', async ({ page }) => {
|
||||
await page.goto('/learn');
|
||||
|
||||
await expect(page).toHaveTitle(
|
||||
'Learn to Code — For Free — Coding Courses for Busy People'
|
||||
);
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
level: 1,
|
||||
name: 'Welcome back, Full Stack User.'
|
||||
})
|
||||
).toBeVisible();
|
||||
|
||||
const shownQuote = await page.getByTestId('random-quote').textContent();
|
||||
|
||||
const shownAuthorText = await page
|
||||
.getByTestId('random-author')
|
||||
.textContent();
|
||||
|
||||
const shownAuthor = shownAuthorText?.replace('- ', '');
|
||||
|
||||
const allMotivationalQuotes = words.motivationalQuotes.map(mq => mq.quote);
|
||||
const allAuthors = words.motivationalQuotes.map(mq => mq.author);
|
||||
|
||||
expect(allMotivationalQuotes).toContain(shownQuote);
|
||||
expect(allAuthors).toContain(shownAuthor);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { focusEditor } from './utils/editor';
|
||||
|
||||
test.describe('Editor Shortcuts', () => {
|
||||
test('Should add a new line if the user presses Alt+Enter', async ({
|
||||
page,
|
||||
isMobile
|
||||
}) => {
|
||||
await page.goto(
|
||||
'learn/responsive-web-design/basic-html-and-html5/say-hello-to-html-elements'
|
||||
);
|
||||
await focusEditor({ page, isMobile });
|
||||
|
||||
await page.keyboard.press('Alt+Enter');
|
||||
await expect(
|
||||
page
|
||||
.getByTestId('editor-container-indexhtml')
|
||||
.getByText('<h1>Hello</h1>\n')
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('Should not add a new line if the user presses Ctrl+Enter', async ({
|
||||
page,
|
||||
isMobile
|
||||
}) => {
|
||||
await page.goto(
|
||||
'learn/responsive-web-design/basic-html-and-html5/say-hello-to-html-elements'
|
||||
);
|
||||
await focusEditor({ page, isMobile });
|
||||
|
||||
await page.keyboard.press('Control+Enter');
|
||||
await expect(
|
||||
page.getByTestId('editor-container-indexhtml').getByText('<h1>Hello</h1>')
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import translations from '../client/i18n/locales/english/translations.json';
|
||||
import { alertToBeVisible } from './utils/alerts';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(
|
||||
'/learn/foundational-c-sharp-with-microsoft/write-your-first-code-using-c-sharp/trophy-write-your-first-code-using-c-sharp'
|
||||
);
|
||||
});
|
||||
|
||||
test.describe('Link MS user component (signed-out user)', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
test('should display the page content with a signin CTA', async ({
|
||||
page
|
||||
}) => {
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: 'Trophy - Write Your First Code Using C#',
|
||||
level: 1
|
||||
})
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: translations.learn.ms['link-header'],
|
||||
level: 2
|
||||
})
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText(translations.learn.ms['link-signin'])
|
||||
).toBeVisible();
|
||||
|
||||
// There are 2 sign in button on the page: one in the navbar, and one in the page content
|
||||
const signInButtons = await page
|
||||
.getByRole('link', { name: translations.buttons['sign-in'] })
|
||||
.all();
|
||||
expect(signInButtons).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Link MS user component (signed-in user)', () => {
|
||||
test.afterEach(() => {
|
||||
execSync('node ../tools/scripts/seed/seed-ms-username');
|
||||
});
|
||||
|
||||
test("should recognize the user's MS account", async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: 'Trophy - Write Your First Code Using C#',
|
||||
level: 1
|
||||
})
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page.locator('main')).toHaveText(
|
||||
/The Microsoft account with username "certifieduser" is currently linked to your freeCodeCamp account\. If this is not your Microsoft username, remove the link\./
|
||||
);
|
||||
});
|
||||
|
||||
test('should allow the user to unlink their MS account and display a form for re-link', async ({
|
||||
page
|
||||
}) => {
|
||||
const unlinkButton = page.getByRole('button', {
|
||||
name: translations.buttons['unlink-account']
|
||||
});
|
||||
await expect(unlinkButton).toBeVisible();
|
||||
await unlinkButton.click();
|
||||
|
||||
await alertToBeVisible(page, translations.flash.ms.transcript.unlinked);
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: translations.learn.ms['link-header'],
|
||||
level: 2
|
||||
})
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(translations.learn.ms.unlinked)).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('listitem').filter({
|
||||
hasText:
|
||||
'Using a browser where you are logged into your Microsoft account, go to https://learn.microsoft.com/users/me/transcript'
|
||||
})
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.getByRole('listitem')
|
||||
.filter({ hasText: translations.learn.ms['link-li-2'] })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.getByRole('listitem')
|
||||
.filter({ hasText: translations.learn.ms['link-li-3'] })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.getByRole('listitem')
|
||||
.filter({ hasText: translations.learn.ms['link-li-4'] })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('listitem').filter({
|
||||
hasText:
|
||||
'Paste the URL into the input below, it should look similar to this: https://learn.microsoft.com/LOCALE/users/USERNAME/transcript/ID'
|
||||
})
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.getByRole('listitem')
|
||||
.filter({ hasText: translations.learn.ms['link-li-6'] })
|
||||
).toBeVisible();
|
||||
|
||||
const transcriptLinkInput = page.getByLabel(
|
||||
translations.learn.ms['transcript-label']
|
||||
);
|
||||
await expect(transcriptLinkInput).toBeVisible();
|
||||
await expect(transcriptLinkInput).toHaveAttribute(
|
||||
'placeholder',
|
||||
'https://learn.microsoft.com/en-us/users/username/transcript/transcriptId'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { clearEditor, focusEditor, getEditors } from './utils/editor';
|
||||
import { signout } from './utils/logout';
|
||||
|
||||
// Test the lower jaw on mobile viewport only
|
||||
test.use({
|
||||
viewport: { width: 393, height: 851 },
|
||||
isMobile: true
|
||||
});
|
||||
|
||||
test('Check the initial states of submit button and "check your code" button', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(
|
||||
'/learn/2022/responsive-web-design/learn-html-by-building-a-cat-photo-app/step-3'
|
||||
);
|
||||
const checkButton = page.getByTestId('lowerJaw-check-button');
|
||||
|
||||
const submitButton = page.getByTestId('lowerJaw-submit-button');
|
||||
const checkButtonState = await checkButton.getAttribute('aria-hidden');
|
||||
const submitButtonState = await submitButton.getAttribute('aria-hidden');
|
||||
expect(checkButtonState).toBe(null);
|
||||
expect(submitButtonState).toBe('true');
|
||||
});
|
||||
|
||||
test('Click on the "check your code" button', async ({ page }) => {
|
||||
await page.goto(
|
||||
'/learn/2022/responsive-web-design/learn-html-by-building-a-cat-photo-app/step-3'
|
||||
);
|
||||
const checkButton = page.getByRole('button', { name: 'Check Your Code' });
|
||||
|
||||
await checkButton.click();
|
||||
|
||||
const failing = page.getByTestId('lowerJaw-failing-test-feedback');
|
||||
const hint = page.getByTestId('lowerJaw-failing-hint');
|
||||
await expect(failing).toBeVisible();
|
||||
await expect(hint).toBeVisible();
|
||||
});
|
||||
|
||||
test('Resets the lower jaw when prompted', async ({ page }) => {
|
||||
await page.goto(
|
||||
'/learn/2022/responsive-web-design/learn-html-by-building-a-cat-photo-app/step-3'
|
||||
);
|
||||
const checkButton = page.getByRole('button', { name: 'Check Your Code' });
|
||||
|
||||
await checkButton.click();
|
||||
|
||||
const failing = page.getByTestId('lowerJaw-failing-test-feedback');
|
||||
const hint = page.getByTestId('lowerJaw-failing-hint');
|
||||
await expect(failing).toBeVisible();
|
||||
await expect(hint).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Reset' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('dialog', { name: 'Reset this lesson?' })
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Reset this lesson' }).click();
|
||||
await expect(failing).not.toBeVisible();
|
||||
await expect(hint).not.toBeVisible();
|
||||
|
||||
await expect(checkButton).toBeVisible();
|
||||
});
|
||||
|
||||
test('Checks hotkeys when instruction is focused', async ({
|
||||
page,
|
||||
browserName
|
||||
}) => {
|
||||
await page.goto(
|
||||
'/learn/2022/responsive-web-design/learn-html-by-building-a-cat-photo-app/step-3'
|
||||
);
|
||||
const editor = getEditors(page);
|
||||
const checkButton = page.getByRole('button', { name: 'Check Your Code' });
|
||||
const description = page.locator('#description');
|
||||
|
||||
await editor.fill(
|
||||
'<h2>Cat Photos</h2>\n<p>Everyone loves cute cats online!</p>'
|
||||
);
|
||||
|
||||
await description.click();
|
||||
|
||||
if (browserName === 'webkit') {
|
||||
await page.keyboard.press('Meta+Enter');
|
||||
} else {
|
||||
await page.keyboard.press('Control+Enter');
|
||||
}
|
||||
|
||||
await expect(checkButton).not.toBeFocused();
|
||||
});
|
||||
|
||||
test('Focuses on the submit button after tests passed', async ({
|
||||
page,
|
||||
browserName,
|
||||
isMobile
|
||||
}) => {
|
||||
await page.goto(
|
||||
'/learn/2022/responsive-web-design/learn-html-by-building-a-cat-photo-app/step-3'
|
||||
);
|
||||
const editor = getEditors(page);
|
||||
const checkButton = page.getByRole('button', { name: 'Check Your Code' });
|
||||
const submitButton = page.getByRole('button', {
|
||||
name: 'Submit and go to next challenge'
|
||||
});
|
||||
await focusEditor({ page, isMobile });
|
||||
await clearEditor({ page, browserName, isMobile });
|
||||
|
||||
await editor.fill(
|
||||
'<h2>Cat Photos</h2>\n<p>Everyone loves cute cats online!</p>'
|
||||
);
|
||||
await checkButton.click();
|
||||
|
||||
await expect(submitButton).toBeFocused();
|
||||
});
|
||||
|
||||
test('Prompts unauthenticated user to sign in to save progress', async ({
|
||||
page,
|
||||
browserName,
|
||||
isMobile
|
||||
}) => {
|
||||
await page.goto(
|
||||
'/learn/2022/responsive-web-design/learn-html-by-building-a-cat-photo-app/step-3'
|
||||
);
|
||||
await signout(page);
|
||||
await page.reload();
|
||||
const editor = getEditors(page);
|
||||
const checkButton = page.getByRole('button', { name: 'Check Your Code' });
|
||||
const loginButton = page.getByRole('link', {
|
||||
name: 'Sign in to save your progress'
|
||||
});
|
||||
await focusEditor({ page, isMobile });
|
||||
await clearEditor({ page, browserName, isMobile });
|
||||
|
||||
await editor.fill(
|
||||
'<h2>Cat Photos</h2>\n<p>Everyone loves cute cats online!</p>'
|
||||
);
|
||||
|
||||
await checkButton.click();
|
||||
|
||||
await expect(loginButton).toBeVisible();
|
||||
|
||||
await loginButton.click();
|
||||
|
||||
await page.goBack();
|
||||
|
||||
await expect(loginButton).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Should render UI correctly', async ({ page }) => {
|
||||
await page.goto(
|
||||
'/learn/2022/responsive-web-design/learn-html-by-building-a-cat-photo-app/step-3'
|
||||
);
|
||||
const codeCheckButton = page.getByRole('button', {
|
||||
name: 'Check Your Code'
|
||||
});
|
||||
const lowerJawTips = page.getByTestId('failing-test-feedback');
|
||||
await expect(codeCheckButton).toBeVisible();
|
||||
await expect(lowerJawTips).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('Should display the text of the check code button accordingly based on device type and screen size', async ({
|
||||
page,
|
||||
isMobile,
|
||||
browserName
|
||||
}) => {
|
||||
await page.goto(
|
||||
'/learn/2022/responsive-web-design/learn-html-by-building-a-cat-photo-app/step-3'
|
||||
);
|
||||
if (isMobile) {
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Check Your Code', exact: true })
|
||||
).toBeVisible();
|
||||
} else if (browserName === 'webkit') {
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Check Your Code (Command + Enter)' })
|
||||
).toBeVisible();
|
||||
} else {
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Check Your Code (Ctrl + Enter)' })
|
||||
).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('should display the text of submit and go to next challenge button accordingly based on device type', async ({
|
||||
page,
|
||||
isMobile,
|
||||
browserName
|
||||
}) => {
|
||||
await page.goto(
|
||||
'/learn/2022/responsive-web-design/learn-html-by-building-a-cat-photo-app/step-3'
|
||||
);
|
||||
const editor = getEditors(page);
|
||||
const checkButton = page.getByRole('button', { name: 'Check Your Code' });
|
||||
await focusEditor({ page, isMobile });
|
||||
await clearEditor({ page, browserName, isMobile });
|
||||
|
||||
await editor.fill(
|
||||
'<h2>Cat Photos</h2>\n<p>Everyone loves cute cats online!</p>'
|
||||
);
|
||||
|
||||
await checkButton.click();
|
||||
|
||||
if (isMobile) {
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: 'Submit and go to next challenge',
|
||||
exact: true
|
||||
})
|
||||
).toBeVisible();
|
||||
} else if (browserName === 'webkit') {
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: 'Submit and go to next challenge (Command + Enter)'
|
||||
})
|
||||
).toBeVisible();
|
||||
} else {
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: 'Submit and go to next challenge (Ctrl + Enter)'
|
||||
})
|
||||
).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('Hint text should not contain placeholders `fcc-expected`', async ({
|
||||
page,
|
||||
isMobile,
|
||||
browserName
|
||||
}) => {
|
||||
await page.goto(
|
||||
'learn/2022/responsive-web-design/learn-css-transforms-by-building-a-penguin/step-4'
|
||||
);
|
||||
const editor = getEditors(page);
|
||||
const checkButton = page.getByRole('button', { name: 'Check Your Code' });
|
||||
await focusEditor({ page, isMobile });
|
||||
await clearEditor({ page, browserName, isMobile });
|
||||
|
||||
await editor.fill(
|
||||
'body{background:linear-gradient(45deg, rgb(118, 201, 255), rgb(247, 255, 222));margin:0;padding:0;width:5%;height:100vh}'
|
||||
);
|
||||
await checkButton.click();
|
||||
|
||||
const failingHint = page.getByTestId('lowerJaw-failing-hint');
|
||||
const hintDescriptionElement = failingHint.locator('.hint-description');
|
||||
const hintDescription = hintDescriptionElement.locator('p');
|
||||
await expect(hintDescription).toContainText(
|
||||
'You should give body a width of 100%, but found 5%',
|
||||
{ useInnerText: true }
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import translations from '../client/i18n/locales/english/translations.json';
|
||||
|
||||
// A challenge in a non-legacy public superblock (responsive-web-design-v9)
|
||||
const challengePath =
|
||||
'/learn/responsive-web-design-v9/workshop-cat-photo-app/step-3';
|
||||
|
||||
// A challenge in a non-public superblock — modal should not appear
|
||||
const nonPublicChallengePath =
|
||||
'/learn/foundational-c-sharp-with-microsoft/write-your-first-code-using-c-sharp/write-your-first-c-sharp-code';
|
||||
|
||||
const STORE_KEY = 'mobileAppModalDismissedAt';
|
||||
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const mobileAppModal = translations['mobile-app-modal'] as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
|
||||
test.describe('Mobile App Modal', () => {
|
||||
test.skip(
|
||||
({ isMobile }) => isMobile === false,
|
||||
'Skip on desktop — modal only renders on mobile viewports'
|
||||
);
|
||||
|
||||
test('shows the modal on a public superblock challenge', async ({ page }) => {
|
||||
await page.goto(challengePath);
|
||||
|
||||
await expect(
|
||||
page.getByRole('dialog', { name: mobileAppModal.heading })
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(mobileAppModal.body)).toBeVisible();
|
||||
await expect(page.getByRole('link')).toBeVisible();
|
||||
});
|
||||
|
||||
test('link points to an app store', async ({ page }) => {
|
||||
await page.goto(challengePath);
|
||||
|
||||
const link = page.getByRole('link');
|
||||
const href = await link.getAttribute('href');
|
||||
expect(
|
||||
href ===
|
||||
'https://apps.apple.com/us/app/freecodecamp/id6446908151?itsct=apps_box_link&itscg=30200' ||
|
||||
href ===
|
||||
'https://play.google.com/store/apps/details?id=org.freecodecamp'
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('closing with X hides the modal but shows again on next page load', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(challengePath);
|
||||
await page
|
||||
.getByRole('button', { name: translations.buttons.close })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByRole('dialog', { name: mobileAppModal.heading })
|
||||
).not.toBeVisible();
|
||||
|
||||
// No timestamp stored — modal reappears on next navigation
|
||||
const stored = await page.evaluate(
|
||||
key => localStorage.getItem(key),
|
||||
STORE_KEY
|
||||
);
|
||||
expect(stored).toBeNull();
|
||||
|
||||
await page.goto(
|
||||
'/learn/responsive-web-design-v9/workshop-cat-photo-app/step-4'
|
||||
);
|
||||
await expect(
|
||||
page.getByRole('dialog', { name: mobileAppModal.heading })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('clicking the store link hides the modal but shows again on next page load', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(challengePath);
|
||||
await page.getByRole('link').click();
|
||||
await expect(
|
||||
page.getByRole('dialog', { name: mobileAppModal.heading })
|
||||
).not.toBeVisible();
|
||||
|
||||
const stored = await page.evaluate(
|
||||
key => localStorage.getItem(key),
|
||||
STORE_KEY
|
||||
);
|
||||
expect(stored).toBeNull();
|
||||
});
|
||||
|
||||
test('"do not show me again" hides modal and stores a timestamp', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(challengePath);
|
||||
await page
|
||||
.getByRole('button', { name: mobileAppModal['do-not-show'] })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByRole('dialog', { name: mobileAppModal.heading })
|
||||
).not.toBeVisible();
|
||||
|
||||
const stored = await page.evaluate(
|
||||
key => localStorage.getItem(key),
|
||||
STORE_KEY
|
||||
);
|
||||
expect(stored).not.toBeNull();
|
||||
});
|
||||
|
||||
test('does not show after "do not show" dismissal until 30 days pass', async ({
|
||||
page
|
||||
}) => {
|
||||
// Simulate a recent dismissal
|
||||
await page.addInitScript(
|
||||
({ key, ts }) => localStorage.setItem(key, String(ts)),
|
||||
{ key: STORE_KEY, ts: Date.now() - 1000 }
|
||||
);
|
||||
await page.goto(challengePath);
|
||||
await expect(
|
||||
page.getByRole('dialog', { name: mobileAppModal.heading })
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('shows again after 30 days have passed since "do not show"', async ({
|
||||
page
|
||||
}) => {
|
||||
// Simulate a dismissal more than 30 days ago
|
||||
await page.addInitScript(
|
||||
({ key, ts }) => localStorage.setItem(key, String(ts)),
|
||||
{ key: STORE_KEY, ts: Date.now() - THIRTY_DAYS_MS - 1000 }
|
||||
);
|
||||
await page.goto(challengePath);
|
||||
await expect(
|
||||
page.getByRole('dialog', { name: mobileAppModal.heading })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('does not show on a non-public superblock challenge', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(nonPublicChallengePath);
|
||||
await expect(
|
||||
page.getByRole('dialog', { name: mobileAppModal.heading })
|
||||
).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Mobile App Modal — desktop', () => {
|
||||
test.skip(
|
||||
({ isMobile }) => isMobile === true,
|
||||
'Skip on mobile — this suite verifies the modal is absent on desktop'
|
||||
);
|
||||
|
||||
test('does not show on desktop', async ({ page }) => {
|
||||
await page.goto(challengePath);
|
||||
await expect(
|
||||
page.getByRole('dialog', { name: mobileAppModal.heading })
|
||||
).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { test, expect } from '@playwright/test';
|
||||
import translations from '../client/i18n/locales/english/translations.json';
|
||||
|
||||
const execP = promisify(exec);
|
||||
|
||||
// Seed certified user before all tests to ensure progress exists
|
||||
test.beforeAll(async () => {
|
||||
await execP('node ./tools/scripts/seed/seed-demo-user --certified-user', {
|
||||
cwd: process.cwd().replace('/e2e', '')
|
||||
});
|
||||
});
|
||||
|
||||
// Re-seed after each test to restore progress (in case a test modifies it)
|
||||
test.afterEach(async () => {
|
||||
await execP('node ./tools/scripts/seed/seed-demo-user --certified-user', {
|
||||
cwd: process.cwd().replace('/e2e', '')
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Module reset - Block level (v8 grid layout)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Navigate to a superblock where certified user has completed challenges
|
||||
// Certified user has isRespWebDesignCert: true with completed challenges
|
||||
await page.goto('/learn/2022/responsive-web-design');
|
||||
});
|
||||
|
||||
test('should show reset button on block hover', async ({ page }) => {
|
||||
// First block should have progress for certified user
|
||||
const blockHeader = page.locator('.block-header-wrapper').first();
|
||||
|
||||
// Reset button should be hidden initially (opacity: 0)
|
||||
const resetButton = blockHeader.locator('.block-reset-button');
|
||||
await expect(resetButton).toHaveCSS('opacity', '0');
|
||||
|
||||
// Hover over the block header
|
||||
await blockHeader.hover();
|
||||
|
||||
// Reset button should become visible on hover (opacity: 1 for enabled, 0.3 for disabled)
|
||||
await expect(resetButton).toBeVisible();
|
||||
});
|
||||
|
||||
test('should open reset modal when clicking reset button', async ({
|
||||
page
|
||||
}) => {
|
||||
// Find a block with an enabled reset button (has progress)
|
||||
const blockHeader = page
|
||||
.locator('.block-header-wrapper')
|
||||
.filter({ has: page.locator('.block-reset-button:not(:disabled)') })
|
||||
.first();
|
||||
await blockHeader.hover();
|
||||
|
||||
// Click the reset button
|
||||
const resetButton = blockHeader.locator('.block-reset-button');
|
||||
await resetButton.click();
|
||||
|
||||
// Modal should be visible
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Modal should contain the reset heading
|
||||
await expect(
|
||||
page.getByText(
|
||||
translations.learn['reset-progress-description'].split("'")[0],
|
||||
{ exact: false }
|
||||
)
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should close modal when clicking cancel button', async ({ page }) => {
|
||||
// Find a block with an enabled reset button
|
||||
const blockHeader = page
|
||||
.locator('.block-header-wrapper')
|
||||
.filter({ has: page.locator('.block-reset-button:not(:disabled)') })
|
||||
.first();
|
||||
await blockHeader.hover();
|
||||
await blockHeader.locator('.block-reset-button').click();
|
||||
|
||||
// Modal should be visible
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Click the cancel button
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: translations.learn['reset-progress-nevermind']
|
||||
})
|
||||
.click();
|
||||
|
||||
// Modal should be hidden
|
||||
await expect(page.getByRole('dialog')).toBeHidden();
|
||||
});
|
||||
|
||||
test('should disable reset button until verification phrase is entered', async ({
|
||||
page
|
||||
}) => {
|
||||
// Find a block with an enabled reset button
|
||||
const blockHeader = page
|
||||
.locator('.block-header-wrapper')
|
||||
.filter({ has: page.locator('.block-reset-button:not(:disabled)') })
|
||||
.first();
|
||||
await blockHeader.hover();
|
||||
await blockHeader.locator('.block-reset-button').click();
|
||||
|
||||
// Confirm button should be disabled initially
|
||||
const confirmButton = page.getByRole('button', {
|
||||
name: translations.learn['reset-progress-confirm']
|
||||
});
|
||||
await expect(confirmButton).toBeDisabled();
|
||||
|
||||
// Enter incorrect text
|
||||
const verifyInput = page.getByRole('textbox');
|
||||
await verifyInput.fill('wrong text');
|
||||
await expect(confirmButton).toBeDisabled();
|
||||
|
||||
// Enter correct verification phrase
|
||||
await verifyInput.fill(translations.learn['reset-progress-verify']);
|
||||
await expect(confirmButton).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Module reset - Full reset flow', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/learn/2022/responsive-web-design');
|
||||
});
|
||||
|
||||
test('should reset block progress when confirmed', async ({ page }) => {
|
||||
// Find a block with an enabled reset button (has progress)
|
||||
const blockHeader = page
|
||||
.locator('.block-header-wrapper')
|
||||
.filter({ has: page.locator('.block-reset-button:not(:disabled)') })
|
||||
.first();
|
||||
|
||||
// Open the reset modal
|
||||
await blockHeader.hover();
|
||||
await blockHeader.locator('.block-reset-button').click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Enter verification phrase
|
||||
const verifyInput = page.getByRole('textbox');
|
||||
await verifyInput.fill(translations.learn['reset-progress-verify']);
|
||||
|
||||
const confirmButton = page.getByRole('button', {
|
||||
name: translations.learn['reset-progress-confirm']
|
||||
});
|
||||
await confirmButton.click();
|
||||
|
||||
await expect(
|
||||
page.getByText(
|
||||
translations.learn['reset-progress-success'].split("'")[0],
|
||||
{ exact: false }
|
||||
)
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: translations.learn['reset-progress-dismiss']
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
// Skip: full-stack-developer page is not built in dev environment
|
||||
test.describe.skip('Module reset - Chapter level (v9 accordion layout)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Navigate to a chapter-based superblock
|
||||
await page.goto('/learn/full-stack-developer');
|
||||
});
|
||||
|
||||
test('should show reset button on chapter hover', async ({ page }) => {
|
||||
// Find a chapter header wrapper
|
||||
const chapterHeader = page.locator('.chapter-header-wrapper').first();
|
||||
|
||||
// Reset button should be hidden initially
|
||||
const resetButton = chapterHeader.locator('.block-reset-button');
|
||||
await expect(resetButton).toHaveCSS('opacity', '0');
|
||||
|
||||
// Hover over the chapter header
|
||||
await chapterHeader.hover();
|
||||
|
||||
// Reset button should become visible on hover
|
||||
await expect(resetButton).toHaveCSS('opacity', '1');
|
||||
});
|
||||
|
||||
test('should show reset button on module hover', async ({ page }) => {
|
||||
// Expand a chapter first
|
||||
const chapterButton = page.locator('.chapter-button-main').first();
|
||||
await chapterButton.click();
|
||||
|
||||
// Find a module header wrapper
|
||||
const moduleHeader = page.locator('.module-header-wrapper').first();
|
||||
|
||||
// Hover over the module header
|
||||
await moduleHeader.hover();
|
||||
|
||||
// Reset button should become visible on hover
|
||||
const resetButton = moduleHeader.locator('.block-reset-button');
|
||||
await expect(resetButton).toHaveCSS('opacity', '1');
|
||||
});
|
||||
|
||||
test('should open reset modal for chapter with correct title', async ({
|
||||
page
|
||||
}) => {
|
||||
// Hover over a chapter to reveal the reset button
|
||||
const chapterHeader = page.locator('.chapter-header-wrapper').first();
|
||||
await chapterHeader.hover();
|
||||
|
||||
// Click the reset button
|
||||
const resetButton = chapterHeader.locator('.block-reset-button');
|
||||
await resetButton.click();
|
||||
|
||||
// Modal should be visible with the chapter name in the heading
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Module reset - Accessibility', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/learn/2022/responsive-web-design');
|
||||
});
|
||||
|
||||
test('reset button should have proper aria-label', async ({ page }) => {
|
||||
// Find a reset button and check its aria-label
|
||||
const resetButton = page.locator('.block-reset-button').first();
|
||||
const ariaLabel = await resetButton.getAttribute('aria-label');
|
||||
|
||||
// Should contain "Reset progress for"
|
||||
expect(ariaLabel).toContain('Reset progress for');
|
||||
});
|
||||
|
||||
test('modal should be keyboard accessible', async ({ page }) => {
|
||||
// Find a block with an enabled reset button
|
||||
const blockHeader = page
|
||||
.locator('.block-header-wrapper')
|
||||
.filter({ has: page.locator('.block-reset-button:not(:disabled)') })
|
||||
.first();
|
||||
await blockHeader.hover();
|
||||
await blockHeader.locator('.block-reset-button').click();
|
||||
|
||||
// Modal should be visible
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Press Escape to close
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Modal should be hidden
|
||||
await expect(page.getByRole('dialog')).toBeHidden();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import translations from '../client/i18n/locales/english/translations.json';
|
||||
|
||||
const verifyTrophyButtonText = translations.buttons['verify-trophy'];
|
||||
const askForHelpButtonText = translations.buttons['ask-for-help'];
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(
|
||||
'/learn/foundational-c-sharp-with-microsoft/write-your-first-code-using-c-sharp/trophy-write-your-first-code-using-c-sharp'
|
||||
);
|
||||
});
|
||||
|
||||
test('the page should render with correct title and description', async ({
|
||||
page
|
||||
}) => {
|
||||
await expect(page).toHaveTitle(
|
||||
'Write Your First Code Using C# - Trophy - Write Your First Code Using C# | Learn | freeCodeCamp.org'
|
||||
);
|
||||
const title = page.getByTestId('challenge-title');
|
||||
await expect(title).toBeVisible();
|
||||
await expect(title).toContainText('Trophy - Write Your First Code Using C#');
|
||||
|
||||
const description = page.getByTestId('challenge-description');
|
||||
await expect(description).toBeVisible();
|
||||
});
|
||||
|
||||
test('Correct Verify Trophy button', async ({ page }) => {
|
||||
const askHelpButton = page.getByRole('button', {
|
||||
name: verifyTrophyButtonText
|
||||
});
|
||||
await expect(askHelpButton).toBeVisible();
|
||||
await expect(askHelpButton).toHaveText(verifyTrophyButtonText);
|
||||
await expect(askHelpButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test('Correct Ask for help button', async ({ page }) => {
|
||||
const checkAnswerButton = page.getByRole('button', {
|
||||
name: askForHelpButtonText
|
||||
});
|
||||
await expect(checkAnswerButton).toBeVisible();
|
||||
await expect(checkAnswerButton).toContainText(askForHelpButtonText);
|
||||
|
||||
await checkAnswerButton.click();
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: translations.buttons['get-help'],
|
||||
exact: true
|
||||
})
|
||||
).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { execSync } from 'child_process';
|
||||
import { test, expect } from '@playwright/test';
|
||||
import translations from '../client/i18n/locales/english/translations.json';
|
||||
import { clearEditor, focusEditor } from './utils/editor';
|
||||
|
||||
test.describe('multifileCertProjects', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
execSync('node ../tools/scripts/seed/seed-demo-user --certified-user');
|
||||
await page.goto(
|
||||
'learn/2022/responsive-web-design/build-a-tribute-page-project/build-a-tribute-page'
|
||||
);
|
||||
});
|
||||
|
||||
const success =
|
||||
/Your code was saved to the database\. It will be here when you return\./;
|
||||
const tooFast =
|
||||
/Slow Down! Your code was not saved\. Try again in a few seconds\./;
|
||||
|
||||
test('should save and reload user code', async ({
|
||||
page,
|
||||
isMobile,
|
||||
browserName
|
||||
}) => {
|
||||
await focusEditor({ page, isMobile });
|
||||
await clearEditor({ page, browserName });
|
||||
|
||||
await page.keyboard.type('save1text');
|
||||
await expect(page.getByText('save1text')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: translations.buttons.save }).click();
|
||||
|
||||
await expect(page.getByTestId('flash-message')).toContainText(success);
|
||||
|
||||
await page.reload();
|
||||
|
||||
await focusEditor({ page, isMobile });
|
||||
|
||||
await expect(page.getByText('save1text')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should save using ctrl+s hotkey and persist through navigation', async ({
|
||||
page,
|
||||
isMobile,
|
||||
browserName
|
||||
}) => {
|
||||
test.skip(isMobile);
|
||||
|
||||
await focusEditor({ page, isMobile });
|
||||
await clearEditor({ page, browserName });
|
||||
|
||||
await page.keyboard.type('save2text');
|
||||
await expect(page.getByText('save2text')).toBeVisible();
|
||||
|
||||
await page.keyboard.down('Control');
|
||||
await page.keyboard.press('KeyS');
|
||||
|
||||
await expect(page.getByTestId('flash-message')).toContainText(success);
|
||||
|
||||
await page.getByRole('button', { name: 'Close' }).click();
|
||||
|
||||
await expect(page.getByText('save2text')).toBeVisible();
|
||||
|
||||
await page.reload();
|
||||
|
||||
await expect(page.getByText('save2text')).toBeVisible();
|
||||
await focusEditor({ page, isMobile });
|
||||
|
||||
await page.keyboard.down('Control');
|
||||
await page.keyboard.press('KeyS');
|
||||
|
||||
await expect(page.getByTestId('flash-message')).toContainText(tooFast);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
import { clearEditor, focusEditor, getEditors } from './utils/editor';
|
||||
import solution from './fixtures/learn-basic-css-by-building-a-cafe-menu-15.json';
|
||||
import { isMacOS } from './utils/user-agent';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(
|
||||
'/learn/2022/responsive-web-design/learn-basic-css-by-building-a-cafe-menu/step-15'
|
||||
);
|
||||
});
|
||||
|
||||
test.describe('MultifileEditor Component', () => {
|
||||
test('Multiple editors should be selected and able to insert text into', async ({
|
||||
page
|
||||
}) => {
|
||||
// Spawn second editor to test MultifileEditor component
|
||||
const stylesEditor = page.getByRole('button', {
|
||||
name: 'styles.css Editor'
|
||||
});
|
||||
await stylesEditor.click();
|
||||
|
||||
// Use the `.toHaveCount()` assertion to ensure that the second editor is on the screen
|
||||
// before moving onto other assertions.
|
||||
// Note that using the `.all()` locator here would result a flaky test.
|
||||
// Ref: https://github.com/freeCodeCamp/freeCodeCamp/pull/53031/files#r1500316812
|
||||
const editors = getEditors(page);
|
||||
await expect(editors).toHaveCount(2);
|
||||
|
||||
const test_string = 'TestString';
|
||||
let index = 0;
|
||||
for (const editor of await editors.all()) {
|
||||
await editor.focus();
|
||||
await page.keyboard.insertText(test_string + index.toString());
|
||||
const text = page.getByText(test_string + index.toString());
|
||||
await expect(text).toBeVisible();
|
||||
index++;
|
||||
}
|
||||
});
|
||||
|
||||
test('Reloading should preserve the editor layout', async ({
|
||||
page,
|
||||
isMobile
|
||||
}) => {
|
||||
test.skip(
|
||||
isMobile,
|
||||
'The mobile layout does not have resizable panes, so this test is not applicable.'
|
||||
);
|
||||
|
||||
const desktopLayout = page.getByTestId('desktop-layout');
|
||||
const splitter = desktopLayout.getByTestId('preview-left-splitter');
|
||||
const editorPane = desktopLayout.getByTestId('editor-pane');
|
||||
const initialStyle = await editorPane.getAttribute('style');
|
||||
expect(initialStyle).toContain('flex: 1');
|
||||
|
||||
// Drag the splitter to resize the editor pane
|
||||
await splitter.hover();
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(100, 100);
|
||||
await page.mouse.up();
|
||||
|
||||
const newStyle = await editorPane.getAttribute('style');
|
||||
// It doesn't matter where it's dragged to, just that it's different:
|
||||
expect(newStyle).not.toContain('flex: 1');
|
||||
|
||||
await page.reload();
|
||||
|
||||
expect(await editorPane.getAttribute('style')).toBe(newStyle);
|
||||
});
|
||||
|
||||
test('Multiple open editors should remain open on moving to next challenge', async ({
|
||||
page,
|
||||
isMobile,
|
||||
browserName,
|
||||
context
|
||||
}) => {
|
||||
test.skip(
|
||||
browserName !== 'chromium',
|
||||
'Only chromium allows us to use the clipboard API.'
|
||||
);
|
||||
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
|
||||
await focusEditor({ page, isMobile });
|
||||
await clearEditor({ page, browserName });
|
||||
|
||||
await page.evaluate(
|
||||
async solution => await navigator.clipboard.writeText(solution.content),
|
||||
solution
|
||||
);
|
||||
|
||||
if (isMacOS) {
|
||||
await page.keyboard.press('Meta+v');
|
||||
} else {
|
||||
await page.keyboard.press('Control+v');
|
||||
}
|
||||
|
||||
const stylesEditor = page.getByRole('button', {
|
||||
name: 'styles.css Editor'
|
||||
});
|
||||
await stylesEditor.click();
|
||||
const editorsCurrentPage = getEditors(page);
|
||||
await expect(editorsCurrentPage).toHaveCount(2);
|
||||
|
||||
await page.keyboard.press('Control+Enter');
|
||||
|
||||
const submitButton = page.getByRole('button', {
|
||||
name: 'Submit and Continue'
|
||||
});
|
||||
|
||||
// Mobile screen shifts submit button out of view and Playwright fails at scrolling with multiple editors open
|
||||
if (isMobile) {
|
||||
await submitButton.dispatchEvent('click');
|
||||
} else {
|
||||
await submitButton.click();
|
||||
}
|
||||
|
||||
const editorsNextPage = getEditors(page);
|
||||
await expect(editorsNextPage).toHaveCount(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import translations from '../client/i18n/locales/english/translations.json';
|
||||
|
||||
const pageWithSpeaking =
|
||||
'/learn/b1-english-for-developers/learn-about-adverbial-phrases/task-19';
|
||||
const pageWithoutSpeaking =
|
||||
'/learn/responsive-web-design-v9/lecture-what-is-css/what-is-the-basic-anatomy-of-a-css-rule';
|
||||
|
||||
test.describe('Multiple Choice Question Challenge - With Speaking Modal', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(pageWithSpeaking);
|
||||
});
|
||||
|
||||
test('should show the speaking button and open the speaking modal', async ({
|
||||
page,
|
||||
browserName
|
||||
}) => {
|
||||
test.skip(
|
||||
browserName === 'firefox',
|
||||
'Skip on Firefox - speech recognition unsupported'
|
||||
);
|
||||
|
||||
const speakingButtons = page.getByRole('button', {
|
||||
name: translations['speaking-modal']['speaking-button']
|
||||
});
|
||||
await expect(speakingButtons).toHaveCount(2);
|
||||
|
||||
await expect(page.getByRole('radio')).toHaveCount(2);
|
||||
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const btn = speakingButtons.nth(i);
|
||||
await expect(btn).toBeVisible();
|
||||
|
||||
const describedBy = await btn.getAttribute('aria-describedby');
|
||||
expect(describedBy).toBeTruthy();
|
||||
|
||||
// Ensure aria-describedby points to an existing element
|
||||
await expect(page.locator(`#${describedBy}`)).toBeVisible();
|
||||
|
||||
await expect(btn).toHaveAttribute(
|
||||
'aria-label',
|
||||
translations['speaking-modal']['speaking-button']
|
||||
);
|
||||
}
|
||||
|
||||
await speakingButtons.first().click();
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
const playButton = page.getByRole('button', {
|
||||
name: translations['speaking-modal']['play']
|
||||
});
|
||||
await expect(playButton).toBeVisible();
|
||||
await expect(playButton).toBeFocused(); // The button is focused by default
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: translations['speaking-modal']['record']
|
||||
})
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show not-supported message on Firefox', async ({
|
||||
page,
|
||||
browserName
|
||||
}) => {
|
||||
test.skip(
|
||||
browserName !== 'firefox',
|
||||
'Run only on Firefox to validate unsupported path'
|
||||
);
|
||||
|
||||
const radioCount = await page.getByRole('radio').count();
|
||||
expect(radioCount).toBeGreaterThan(1);
|
||||
|
||||
const speakingButton = page
|
||||
.getByRole('button', {
|
||||
name: translations['speaking-modal']['speaking-button']
|
||||
})
|
||||
.first();
|
||||
await expect(speakingButton).toBeVisible();
|
||||
|
||||
await speakingButton.click();
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText(
|
||||
translations['speaking-modal']['speech-recognition-not-supported']
|
||||
)
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Multiple Choice Question Challenge - Without Speaking Modal', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(pageWithoutSpeaking);
|
||||
});
|
||||
|
||||
test('should not show speaking controls on a challenge without speaking', async ({
|
||||
page
|
||||
}) => {
|
||||
await expect(page.getByRole('radio')).toHaveCount(12);
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: translations['speaking-modal']['speaking-button']
|
||||
})
|
||||
).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import solution from './fixtures/build-a-personal-portfolio-webpage.json';
|
||||
import { clearEditor, focusEditor } from './utils/editor';
|
||||
import { isMacOS } from './utils/user-agent';
|
||||
|
||||
// middle of block
|
||||
const challenge1 = {
|
||||
url: '/learn/front-end-development-libraries/front-end-development-libraries-projects/build-a-javascript-calculator',
|
||||
nextUrl:
|
||||
'/learn/front-end-development-libraries/front-end-development-libraries-projects/build-a-25--5-clock'
|
||||
};
|
||||
|
||||
// last in superblock
|
||||
const challenge2 = {
|
||||
url: '/learn/college-algebra-with-python/build-a-data-graph-explorer-project/build-a-data-graph-explorer',
|
||||
nextUrl:
|
||||
'/learn/college-algebra-with-python/#build-a-data-graph-explorer-project'
|
||||
};
|
||||
|
||||
const rwdChallenge = {
|
||||
url: '/learn/2022/responsive-web-design/build-a-personal-portfolio-webpage-project/build-a-personal-portfolio-webpage',
|
||||
nextUrl:
|
||||
'/learn/2022/responsive-web-design/#build-a-personal-portfolio-webpage-project'
|
||||
};
|
||||
test.describe('Navigation from the middle or end (URL solution)', () => {
|
||||
test('In the middle of a block should take you to the next challenge', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(challenge1.url);
|
||||
await page
|
||||
.getByRole('textbox', { name: 'solution' })
|
||||
.fill('https://example.com');
|
||||
await page.keyboard.press('Enter');
|
||||
await page
|
||||
.getByRole('button', { name: 'Submit and go to next challenge' })
|
||||
.click();
|
||||
await page.waitForURL(challenge1.nextUrl);
|
||||
});
|
||||
|
||||
test('at the end of a superblock should take you to the superblock page with the current block hash', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(challenge2.url);
|
||||
await page
|
||||
.getByRole('textbox', { name: 'solution' })
|
||||
.fill('https://example.com');
|
||||
await page.keyboard.press('Enter');
|
||||
await page
|
||||
.getByRole('button', { name: 'Submit and go to next challenge' })
|
||||
.click();
|
||||
await page.waitForURL(challenge2.nextUrl);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Should take you to the next superblock (with editor solution)', () => {
|
||||
test.skip(
|
||||
({ browserName }) => browserName !== 'chromium',
|
||||
'Only chromium allows us to use the clipboard API.'
|
||||
);
|
||||
test('at the end of a superblock should take you to the superblock page with the current block hash', async ({
|
||||
page,
|
||||
isMobile,
|
||||
browserName,
|
||||
context
|
||||
}) => {
|
||||
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
await page.goto(rwdChallenge.url);
|
||||
await focusEditor({ page, isMobile });
|
||||
await clearEditor({ page, browserName });
|
||||
|
||||
await page.evaluate(
|
||||
async solution => await navigator.clipboard.writeText(solution.content),
|
||||
solution
|
||||
);
|
||||
|
||||
if (isMacOS) {
|
||||
await page.keyboard.press('Meta+v');
|
||||
} else {
|
||||
await page.keyboard.press('Control+v');
|
||||
}
|
||||
|
||||
await page.keyboard.press('Control+Enter');
|
||||
|
||||
const submitButton = page.locator(
|
||||
'[data-playwright-test-label="independentLowerJaw-submit-button"]'
|
||||
);
|
||||
await expect(submitButton).toBeVisible();
|
||||
await submitButton.click();
|
||||
await page.waitForURL(rwdChallenge.nextUrl);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,325 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
import translations from '../client/i18n/locales/english/translations.json';
|
||||
import { clearEditor, focusEditor, getEditors } from './utils/editor';
|
||||
|
||||
const outputTexts = {
|
||||
default: `
|
||||
/**
|
||||
* Your test output will go here
|
||||
*/`,
|
||||
syntaxError: `SyntaxError: unknown: Unexpected token (1:3)
|
||||
|
||||
> 1 | var
|
||||
| ^`,
|
||||
empty: `// running tests
|
||||
1. You should declare myName with the var keyword, ending with a semicolon
|
||||
// tests completed`,
|
||||
passed: `// running tests
|
||||
// tests completed`
|
||||
};
|
||||
|
||||
interface InsertTextParameters {
|
||||
page: Page;
|
||||
browserName: string;
|
||||
containerId?: string;
|
||||
isMobile: boolean;
|
||||
text: string;
|
||||
updatesConsole?: boolean;
|
||||
}
|
||||
|
||||
const replaceTextInCodeEditor = async ({
|
||||
page,
|
||||
browserName,
|
||||
isMobile,
|
||||
text,
|
||||
containerId = 'editor-container-indexhtml'
|
||||
}: InsertTextParameters) => {
|
||||
await expect(async () => {
|
||||
await clearEditor({ page, browserName, isMobile });
|
||||
await getEditors(page).fill(text);
|
||||
await expect(page.getByTestId(containerId)).toContainText(text);
|
||||
|
||||
await expect(
|
||||
page.getByRole('region', {
|
||||
name: translations.learn['editor-tabs'].console
|
||||
})
|
||||
).toContainText('Your test output will go here');
|
||||
}).toPass();
|
||||
};
|
||||
|
||||
const runChallengeTest = async (page: Page, isMobile: boolean) => {
|
||||
if (isMobile) {
|
||||
await page.getByRole('tab', { name: 'Console' }).click();
|
||||
await page.getByText('Run').click();
|
||||
} else {
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: translations.buttons['check-code']
|
||||
})
|
||||
.click();
|
||||
}
|
||||
};
|
||||
|
||||
test.describe('For classic challenges', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(
|
||||
'/learn/responsive-web-design/basic-html-and-html5/say-hello-to-html-elements'
|
||||
);
|
||||
});
|
||||
|
||||
test('it renders the default output', async ({ page, isMobile }) => {
|
||||
if (isMobile) {
|
||||
await page.getByRole('tab', { name: 'Console' }).click();
|
||||
}
|
||||
|
||||
await expect(
|
||||
page.getByRole('region', {
|
||||
name: translations.learn['editor-tabs'].console
|
||||
})
|
||||
).toHaveText(outputTexts.default);
|
||||
});
|
||||
|
||||
test('shows test output when the tests are run', async ({
|
||||
page,
|
||||
isMobile,
|
||||
browserName
|
||||
}) => {
|
||||
const closeButton = page.getByRole('button', { name: 'Close' });
|
||||
await expect(page).toHaveTitle(
|
||||
'Basic HTML and HTML5: Say Hello to HTML Elements |' + ' freeCodeCamp.org'
|
||||
);
|
||||
await focusEditor({ page, isMobile });
|
||||
await replaceTextInCodeEditor({
|
||||
browserName,
|
||||
page,
|
||||
isMobile,
|
||||
text: '<h1>Hello World</h1>'
|
||||
});
|
||||
await runChallengeTest(page, isMobile);
|
||||
if (isMobile) {
|
||||
await closeButton.click();
|
||||
}
|
||||
|
||||
await expect(
|
||||
page.getByRole('region', {
|
||||
name: translations.learn['editor-tabs'].console
|
||||
})
|
||||
).toHaveText(outputTexts.passed);
|
||||
});
|
||||
|
||||
test('shows test output when the tests are triggered by the keyboard', async ({
|
||||
page,
|
||||
browserName,
|
||||
isMobile
|
||||
}) => {
|
||||
test.skip(isMobile);
|
||||
const closeButton = page.getByRole('button', { name: 'Close' });
|
||||
|
||||
await replaceTextInCodeEditor({
|
||||
page,
|
||||
browserName,
|
||||
isMobile,
|
||||
text: '<h1>Hello World</h1>'
|
||||
});
|
||||
await page.keyboard.press('Control+Enter');
|
||||
await closeButton.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('region', {
|
||||
name: translations.learn['editor-tabs'].console
|
||||
})
|
||||
).toHaveText(outputTexts.passed);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Challenge Output Component Tests', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(
|
||||
'/learn/javascript-algorithms-and-data-structures/basic-javascript/declare-javascript-variables'
|
||||
);
|
||||
});
|
||||
|
||||
test('should render with default output', async ({ page, isMobile }) => {
|
||||
if (isMobile) {
|
||||
await page.getByRole('tab', { name: 'Console' }).click();
|
||||
}
|
||||
|
||||
const outputConsole = page.getByRole('region', {
|
||||
name: translations.learn['editor-tabs'].console
|
||||
});
|
||||
|
||||
await expect(outputConsole).toBeVisible();
|
||||
await expect(outputConsole).toHaveText(outputTexts.default);
|
||||
});
|
||||
|
||||
test('should contain syntax error output when var is entered in editor', async ({
|
||||
page,
|
||||
isMobile,
|
||||
browserName
|
||||
}) => {
|
||||
await focusEditor({ page, isMobile });
|
||||
await replaceTextInCodeEditor({
|
||||
browserName,
|
||||
page,
|
||||
isMobile,
|
||||
text: 'var',
|
||||
containerId: 'editor-container-scriptjs',
|
||||
updatesConsole: true
|
||||
});
|
||||
|
||||
if (isMobile) {
|
||||
await page.getByRole('tab', { name: 'Console' }).click();
|
||||
}
|
||||
|
||||
await expect(
|
||||
page.getByRole('region', {
|
||||
name: translations.learn['editor-tabs'].console
|
||||
})
|
||||
).toHaveText(outputTexts.syntaxError);
|
||||
});
|
||||
|
||||
test('should contain a reference error when an undefined var is entered in editor', async ({
|
||||
browserName,
|
||||
page,
|
||||
isMobile
|
||||
}) => {
|
||||
await focusEditor({ page, isMobile });
|
||||
await replaceTextInCodeEditor({
|
||||
browserName,
|
||||
page,
|
||||
isMobile,
|
||||
text: 'myName',
|
||||
containerId: 'editor-container-scriptjs',
|
||||
updatesConsole: true
|
||||
});
|
||||
|
||||
if (isMobile) {
|
||||
await page.getByRole('tab', { name: 'Console' }).click();
|
||||
}
|
||||
|
||||
await expect(
|
||||
page.getByRole('region', {
|
||||
name: translations.learn['editor-tabs'].console
|
||||
})
|
||||
).toContainText('ReferenceError: myName is not defined');
|
||||
});
|
||||
|
||||
test('should contain final output after test fail', async ({
|
||||
page,
|
||||
isMobile
|
||||
}) => {
|
||||
await expect(async () => {
|
||||
await runChallengeTest(page, isMobile);
|
||||
|
||||
expect(await page.getByTestId('output-text').textContent()).toEqual(
|
||||
expect.stringContaining(outputTexts.empty)
|
||||
);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
test('should contain final output after test pass', async ({
|
||||
browserName,
|
||||
page,
|
||||
isMobile
|
||||
}) => {
|
||||
const closeButton = page.getByRole('button', { name: 'Close' });
|
||||
await focusEditor({ page, isMobile });
|
||||
await expect(async () => {
|
||||
await replaceTextInCodeEditor({
|
||||
browserName,
|
||||
page,
|
||||
isMobile,
|
||||
text: 'var myName;',
|
||||
containerId: 'editor-container-scriptjs'
|
||||
});
|
||||
await runChallengeTest(page, isMobile);
|
||||
await expect(closeButton).toBeVisible();
|
||||
}).toPass();
|
||||
await closeButton.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('region', {
|
||||
name: translations.learn['editor-tabs'].console
|
||||
})
|
||||
).toHaveText(outputTexts.passed);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Jquery challenges', () => {
|
||||
test('Jquery challenge should render with default output', async ({
|
||||
page,
|
||||
isMobile
|
||||
}) => {
|
||||
await page.goto(
|
||||
'/learn/front-end-development-libraries/jquery/target-html-elements-with-selectors-using-jquery'
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
await page.getByRole('tab', { name: 'Console' }).click();
|
||||
}
|
||||
|
||||
await expect(
|
||||
page.getByRole('region', {
|
||||
name: translations.learn['editor-tabs'].console
|
||||
})
|
||||
).toHaveText(outputTexts.default);
|
||||
|
||||
await expect(
|
||||
page.getByRole('region', {
|
||||
name: translations.learn['editor-tabs'].console
|
||||
})
|
||||
).not.toHaveText('ReferenceError: $ is not defined');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Custom output for Set and Map', () => {
|
||||
test('Custom output for JavaScript Objects Set and Map', async ({
|
||||
page,
|
||||
isMobile,
|
||||
browserName
|
||||
}) => {
|
||||
await page.goto(
|
||||
'/learn/javascript-algorithms-and-data-structures/basic-javascript/comment-your-javascript-code'
|
||||
);
|
||||
|
||||
await focusEditor({ page, isMobile });
|
||||
|
||||
await replaceTextInCodeEditor({
|
||||
browserName,
|
||||
page,
|
||||
isMobile,
|
||||
text: 'const set = new Set(); set.add(1); set.add("set"); set.add(10); console.log(set);',
|
||||
containerId: 'editor-container-scriptjs'
|
||||
});
|
||||
|
||||
if (isMobile) {
|
||||
await page.getByRole('tab', { name: 'Console' }).click();
|
||||
}
|
||||
|
||||
await expect(
|
||||
page.getByRole('region', {
|
||||
name: translations.learn['editor-tabs'].console
|
||||
})
|
||||
).toContainText(`Set(3) {1, 'set', 10}`);
|
||||
|
||||
await focusEditor({ page, isMobile });
|
||||
await replaceTextInCodeEditor({
|
||||
browserName,
|
||||
page,
|
||||
isMobile,
|
||||
text: 'const map = new Map(); map.set(1, "one"); map.set("two", 2); console.log(map);',
|
||||
containerId: 'editor-container-scriptjs'
|
||||
});
|
||||
|
||||
if (isMobile) {
|
||||
await page.getByRole('tab', { name: 'Console' }).click();
|
||||
}
|
||||
|
||||
await expect(
|
||||
page.getByRole('region', {
|
||||
name: translations.learn['editor-tabs'].console
|
||||
})
|
||||
).toContainText(`Map(2) {1 => 'one', 'two' => 2}`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@freecodecamp/e2e",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"lint": "eslint --max-warnings 0",
|
||||
"playwright:watch": "turbo setup && playwright test --ui-port=0",
|
||||
"playwright:run": "turbo setup && playwright test",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"author": "freeCodeCamp <team@freecodecamp.org>",
|
||||
"license": "BSD-3-Clause",
|
||||
"packageManager": "pnpm@10.20.0",
|
||||
"devDependencies": {
|
||||
"@freecodecamp/eslint-config": "workspace:*",
|
||||
"@freecodecamp/shared": "workspace:*",
|
||||
"eslint": "^9.39.1",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import path from 'path';
|
||||
import { config as dotenvConfig } from 'dotenv';
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
const envPath = path.resolve(__dirname, '..', '.env');
|
||||
dotenvConfig({ path: envPath });
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: '.',
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: false,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Fast-fail CI once failures exceed 5 tests. */
|
||||
maxFailures: process.env.CI ? 6 : undefined,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: 1,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: [['html', { outputFolder: 'playwright/reporter' }]],
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
timeout: 15 * 1000,
|
||||
outputDir: 'playwright/test-results',
|
||||
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: process.env.HOME_LOCATION || 'http://127.0.0.1:8000',
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry',
|
||||
storageState: 'playwright/.auth/certified-user.json',
|
||||
/* Use custom test attribute */
|
||||
testIdAttribute: 'data-playwright-test-label',
|
||||
screenshot: 'only-on-failure'
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: 'setup',
|
||||
testMatch: 'global-setup.ts'
|
||||
},
|
||||
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
dependencies: ['setup']
|
||||
},
|
||||
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
dependencies: ['setup']
|
||||
},
|
||||
|
||||
{
|
||||
name: 'webkit',
|
||||
use: { ...devices['Desktop Safari'] },
|
||||
dependencies: ['setup']
|
||||
},
|
||||
|
||||
/* Test against mobile viewports. */
|
||||
{
|
||||
name: 'Mobile Chrome',
|
||||
use: { ...devices['Pixel 5'] },
|
||||
dependencies: ['setup']
|
||||
},
|
||||
{
|
||||
name: 'Mobile Safari',
|
||||
use: { ...devices['iPhone 12'] },
|
||||
dependencies: ['setup']
|
||||
}
|
||||
/* Uncomment the blocks out if you want to enable the mentioned features */
|
||||
/* ====================================================== */
|
||||
/* Test against branded browsers. */
|
||||
// {
|
||||
// name: 'Microsoft Edge',
|
||||
// use: { ...devices['Desktop Edge'], channel: 'msedge' }
|
||||
// },
|
||||
// {
|
||||
// name: 'Google Chrome',
|
||||
// use: { ...devices['Desktop Chrome'], channel: 'chrome' }
|
||||
// }
|
||||
],
|
||||
|
||||
/* Some tests make the api send emails, so we need mailpit to catch them */
|
||||
webServer: {
|
||||
command: 'docker run --rm -p 1025:1025 -p 8025:8025 axllent/mailpit',
|
||||
port: 1025,
|
||||
reuseExistingServer: true,
|
||||
timeout: 180000
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import { execSync } from 'child_process';
|
||||
import { test, expect } from '@playwright/test';
|
||||
import translations from '../client/i18n/locales/english/translations.json';
|
||||
|
||||
test.use({ storageState: 'playwright/.auth/certified-user.json' });
|
||||
|
||||
test.beforeAll(() => {
|
||||
execSync('node ../tools/scripts/seed/seed-demo-user --certified-user');
|
||||
});
|
||||
|
||||
test.afterAll(() => {
|
||||
execSync('node ../tools/scripts/seed/seed-demo-user --certified-user');
|
||||
});
|
||||
|
||||
test.describe('Add Portfolio Item', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/certifieduser');
|
||||
|
||||
// The 'Add portfolio project' icon button is on the profile page directly.
|
||||
// Click it to open the portfolio modal.
|
||||
await page.getByRole('button', { name: 'Add portfolio project' }).click();
|
||||
|
||||
// Wait for the portfolio form to be visible inside the modal.
|
||||
await expect(
|
||||
page.getByLabel(translations.settings.labels.title)
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('The title has validation', async ({ page }) => {
|
||||
await page.getByLabel(translations.settings.labels.title).fill('T');
|
||||
await expect(page.getByTestId('title-validation')).toContainText(
|
||||
'Title is too short'
|
||||
);
|
||||
|
||||
await page
|
||||
.getByLabel(translations.settings.labels.title)
|
||||
.fill(
|
||||
'This is the longest title you will ever see in your entire life, you will never see such a long title again. This is the longest title in existen'
|
||||
);
|
||||
await expect(page.getByTestId('title-validation')).toContainText(
|
||||
'Title is too long'
|
||||
);
|
||||
await page
|
||||
.getByLabel(translations.settings.labels.title)
|
||||
.fill('My portfolio');
|
||||
await expect(page.getByTestId('title-validation')).toBeHidden();
|
||||
});
|
||||
|
||||
test('The url has validation', async ({ page }) => {
|
||||
await page.getByLabel(translations.settings.labels.url).fill('T');
|
||||
await expect(page.getByTestId('url-validation')).toContainText(
|
||||
'Please use a valid URL'
|
||||
);
|
||||
await page
|
||||
.getByLabel(translations.settings.labels.url)
|
||||
.fill('http://helloworld.com');
|
||||
await expect(page.getByTestId('url-validation')).toBeHidden();
|
||||
});
|
||||
|
||||
test('The image has validation', async ({ page }) => {
|
||||
await page.getByLabel(translations.settings.labels.image).fill('T');
|
||||
await expect(page.getByTestId('image-validation')).toContainText(
|
||||
'Please use a valid URL'
|
||||
);
|
||||
await page
|
||||
.getByLabel(translations.settings.labels.image)
|
||||
.fill(
|
||||
'https://cdn.freecodecamp.org/universal/favicons/favicon-32x32.png'
|
||||
);
|
||||
await expect(page.getByTestId('image-validation')).toBeHidden();
|
||||
|
||||
await page
|
||||
.getByLabel(translations.settings.labels.image)
|
||||
.fill('https://cdn.freecodecamp.org/universal/favicons/favicon-32x32.pn');
|
||||
await expect(page.getByTestId('image-validation')).toContainText(
|
||||
'URL must link directly to an image file'
|
||||
);
|
||||
});
|
||||
|
||||
test('The description has validation', async ({ page }) => {
|
||||
await page
|
||||
.getByLabel(translations.settings.labels.description)
|
||||
.fill(
|
||||
'This is the longest description you will ever see in your entire life, you will never see such a long description again. This is the longest description in existence. Nothing will ever come close to be being so long again in the entire history of the world. I only have 30 characters left.'
|
||||
);
|
||||
await expect(page.getByTestId('description-validation')).toContainText(
|
||||
'There is a maximum limit of 288 characters, you have 0 left'
|
||||
);
|
||||
await page
|
||||
.getByLabel(translations.settings.labels.description)
|
||||
.fill('My description');
|
||||
await expect(page.getByTestId('description-validation')).toBeHidden();
|
||||
});
|
||||
|
||||
test('It should be possible to delete a portfolio item', async ({ page }) => {
|
||||
await page
|
||||
.getByLabel(translations.settings.labels.title)
|
||||
.fill('My portfolio');
|
||||
await page
|
||||
.getByLabel(translations.settings.labels.url)
|
||||
.fill('https://my-portfolio.com');
|
||||
await page
|
||||
.getByLabel(translations.settings.labels.image)
|
||||
.fill(
|
||||
'https://cdn.freecodecamp.org/universal/favicons/favicon-32x32.png'
|
||||
);
|
||||
await page
|
||||
.getByLabel(translations.settings.labels.description)
|
||||
.fill('My description');
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: 'Remove this portfolio item' })
|
||||
.click();
|
||||
|
||||
// Modal closes automatically after removal
|
||||
await expect(page.getByRole('alert').first()).toContainText(
|
||||
/We have updated your portfolio/
|
||||
);
|
||||
});
|
||||
|
||||
test('The save button should be disabled when the form is pristine', async ({
|
||||
page
|
||||
}) => {
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Save this portfolio item' })
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
test('It should be possible to add a portfolio item', async ({ page }) => {
|
||||
await page
|
||||
.getByLabel(translations.settings.labels.title)
|
||||
.fill('My portfolio');
|
||||
await page
|
||||
.getByLabel(translations.settings.labels.url)
|
||||
.fill('https://my-portfolio.com');
|
||||
await page
|
||||
.getByLabel(translations.settings.labels.image)
|
||||
.fill(
|
||||
'https://cdn.freecodecamp.org/universal/favicons/favicon-32x32.png'
|
||||
);
|
||||
await page
|
||||
.getByLabel(translations.settings.labels.description)
|
||||
.fill('My description');
|
||||
|
||||
// Wait for async image validation to complete
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Save this portfolio item' })
|
||||
).toBeEnabled();
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: 'Save this portfolio item' })
|
||||
.click();
|
||||
|
||||
// Modal closes automatically after a successful save
|
||||
await expect(page.getByRole('alert').first()).toContainText(
|
||||
/We have updated your portfolio/
|
||||
);
|
||||
});
|
||||
|
||||
// TODO: Add test for viewing portfolio item
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import translations from '../client/i18n/locales/english/translations.json';
|
||||
import { focusEditor } from './utils/editor';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(
|
||||
'/learn/2022/responsive-web-design/learn-html-by-building-a-cat-photo-app/step-3'
|
||||
);
|
||||
});
|
||||
|
||||
test.describe('Challenge Preview Component', () => {
|
||||
test('should render correct output for default and changed code', async ({
|
||||
page,
|
||||
isMobile
|
||||
}) => {
|
||||
if (isMobile) {
|
||||
await page
|
||||
.getByRole('tab', { name: translations.learn['editor-tabs'].preview })
|
||||
.click();
|
||||
}
|
||||
|
||||
// Check default code output
|
||||
await expect(
|
||||
page
|
||||
.frameLocator('.challenge-preview-frame')
|
||||
.getByRole('heading', { name: 'CatPhotoApp' })
|
||||
).toBeVisible();
|
||||
|
||||
// Change code
|
||||
await focusEditor({ page, isMobile });
|
||||
await page.keyboard.insertText('<h1>FreeCodeCamp</h1>');
|
||||
if (isMobile) {
|
||||
await page
|
||||
.getByRole('tab', { name: translations.learn['editor-tabs'].preview })
|
||||
.click();
|
||||
}
|
||||
|
||||
// Check changed code output
|
||||
await expect(
|
||||
page
|
||||
.frameLocator('.challenge-preview-frame')
|
||||
.getByRole('heading', { name: 'FreeCodeCamp' })
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
import translations from '../client/i18n/locales/english/translations.json';
|
||||
|
||||
const certs = [
|
||||
{
|
||||
name: 'Foundational C# with Microsoft',
|
||||
url: '/certification/certifieduser/foundational-c-sharp-with-microsoft'
|
||||
}
|
||||
];
|
||||
|
||||
const betaCerts = [
|
||||
{
|
||||
name: 'A2 English for Developers',
|
||||
url: '/certification/certifieduser/a2-english-for-developers'
|
||||
},
|
||||
{
|
||||
name: 'B1 English for Developers',
|
||||
url: '/certification/certifieduser/b1-english-for-developers'
|
||||
}
|
||||
];
|
||||
|
||||
const legacyCerts = [
|
||||
{
|
||||
name: 'Legacy Responsive Web Design V8',
|
||||
url: '/certification/certifieduser/responsive-web-design'
|
||||
},
|
||||
{
|
||||
name: 'Legacy JavaScript Algorithms and Data Structures V8',
|
||||
url: '/certification/certifieduser/javascript-algorithms-and-data-structures-v8'
|
||||
},
|
||||
{
|
||||
name: 'Front-End Development Libraries V8',
|
||||
url: '/certification/certifieduser/front-end-development-libraries'
|
||||
},
|
||||
{
|
||||
name: 'Data Visualization V8',
|
||||
url: '/certification/certifieduser/data-visualization'
|
||||
},
|
||||
{
|
||||
name: 'Relational Database V8',
|
||||
url: '/certification/certifieduser/relational-database-v8'
|
||||
},
|
||||
{
|
||||
name: 'Back-End Development and APIs V8',
|
||||
url: '/certification/certifieduser/back-end-development-and-apis'
|
||||
},
|
||||
{
|
||||
name: 'Quality Assurance',
|
||||
url: '/certification/certifieduser/quality-assurance-v7'
|
||||
},
|
||||
{
|
||||
name: 'Scientific Computing with Python',
|
||||
url: '/certification/certifieduser/scientific-computing-with-python-v7'
|
||||
},
|
||||
{
|
||||
name: 'Data Analysis with Python',
|
||||
url: '/certification/certifieduser/data-analysis-with-python-v7'
|
||||
},
|
||||
{
|
||||
name: 'Information Security',
|
||||
url: '/certification/certifieduser/information-security-v7'
|
||||
},
|
||||
{
|
||||
name: 'Machine Learning with Python',
|
||||
url: '/certification/certifieduser/machine-learning-with-python-v7'
|
||||
},
|
||||
{
|
||||
name: 'College Algebra with Python',
|
||||
url: '/certification/certifieduser/college-algebra-with-python-v8'
|
||||
},
|
||||
{
|
||||
name: 'Legacy Front-End',
|
||||
url: '/certification/certifieduser/legacy-front-end'
|
||||
},
|
||||
{
|
||||
name: 'Legacy JavaScript Algorithms and Data Structures V7',
|
||||
url: '/certification/certifieduser/javascript-algorithms-and-data-structures'
|
||||
},
|
||||
{
|
||||
name: 'Legacy Back-End',
|
||||
url: '/certification/certifieduser/legacy-back-end'
|
||||
},
|
||||
{
|
||||
name: 'Legacy Data Visualization',
|
||||
url: '/certification/certifieduser/legacy-data-visualization'
|
||||
},
|
||||
{
|
||||
name: 'Legacy Information Security and Quality Assurance',
|
||||
url: '/certification/certifieduser/information-security-and-quality-assurance'
|
||||
},
|
||||
{
|
||||
name: 'Legacy Full-Stack',
|
||||
url: '/certification/certifieduser/full-stack'
|
||||
}
|
||||
];
|
||||
|
||||
test.describe('Profile component', () => {
|
||||
test.describe('when viewing my own profile', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/certifieduser');
|
||||
});
|
||||
|
||||
test('renders the camper profile correctly', async ({ page }) => {
|
||||
// There are multiple avatars on the page, one is in the navbar, one is in the page body.
|
||||
// The avatar we are interested in is the last one in the list
|
||||
const avatar = page
|
||||
.getByRole('img', {
|
||||
name: translations.icons.avatar,
|
||||
includeHidden: true // the svg has `aria-hidden` set to true
|
||||
})
|
||||
.last();
|
||||
|
||||
// "visible" as in the element is in the DOM, but it is hidden from non-sighted users
|
||||
await expect(avatar).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: '@certifieduser' })
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('Full Stack User')).toBeVisible();
|
||||
await expect(page.getByText('Joined November 2020')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(translations.profile.contributor)
|
||||
).toBeVisible();
|
||||
expect(
|
||||
await page.locator('.badge-card-description').textContent()
|
||||
).toContain('Among most prolific volunteers');
|
||||
});
|
||||
|
||||
test('displays certifications correctly', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'freeCodeCamp Certifications' })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Legacy Certifications' })
|
||||
).toBeVisible();
|
||||
|
||||
for (const cert of certs) {
|
||||
const link = page
|
||||
.getByRole('link', {
|
||||
name: `View ${cert.name} Certification`
|
||||
})
|
||||
.first();
|
||||
await expect(link).toBeVisible();
|
||||
await expect(link).toHaveAttribute('href', cert.url);
|
||||
}
|
||||
|
||||
for (const cert of betaCerts) {
|
||||
const link = page
|
||||
.getByRole('link', {
|
||||
name: `View ${cert.name} Certification (Beta)`
|
||||
})
|
||||
.first();
|
||||
await expect(link).toBeVisible();
|
||||
await expect(link).toHaveAttribute('href', cert.url);
|
||||
}
|
||||
|
||||
for (const cert of legacyCerts) {
|
||||
const link = page
|
||||
.getByRole('link', {
|
||||
name: `View ${cert.name} Certification`
|
||||
})
|
||||
.last();
|
||||
await expect(link).toBeVisible();
|
||||
await expect(link).toHaveAttribute('href', cert.url);
|
||||
}
|
||||
});
|
||||
|
||||
test('should show portfolio section with add button when empty', async ({
|
||||
page
|
||||
}) => {
|
||||
// @certifieduser doesn't have portfolio information, but session users
|
||||
// always see the section so they can add projects
|
||||
await expect(
|
||||
page.locator(`h2:text-is("${translations.profile.projects}")`)
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: translations.aria['add-portfolio'] })
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("when viewing someone else's profile", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/publicUser');
|
||||
});
|
||||
|
||||
test.describe('while logged in', () => {
|
||||
test('displays the public username', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { name: '@publicuser' })
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('logged out', () => {
|
||||
test('displays the public username', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { name: '@publicuser' })
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import translations from '../client/i18n/locales/english/translations.json';
|
||||
import { clearEditor, focusEditor } from './utils/editor';
|
||||
|
||||
test.describe('Progress bar component in editor', () => {
|
||||
test('Should appear with the correct content after the user has submitted their code', async ({
|
||||
page,
|
||||
isMobile,
|
||||
browserName
|
||||
}) => {
|
||||
await page.goto(
|
||||
'/learn/2022/responsive-web-design/learn-html-by-building-a-cat-photo-app/step-3'
|
||||
);
|
||||
// If focusEditor fails, typically it's because the instructions are too
|
||||
// large. There's a bug that means `scrollIntoView` does not work in the
|
||||
// editor and so we have to pick less verbose challenges until that's fixed.
|
||||
await focusEditor({ page, isMobile });
|
||||
await clearEditor({ page, browserName });
|
||||
|
||||
await page.keyboard.insertText(
|
||||
'<html><body><h1>CatPhotoApp</h1><h2>Cat Photos</h2><p>Everyone loves cute cats online!</p></body></html>'
|
||||
);
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: translations.buttons['check-code'] })
|
||||
.click();
|
||||
|
||||
const progressBarContainer = page.getByTestId('progress-bar-container');
|
||||
await expect(progressBarContainer).toContainText(
|
||||
'Learn HTML by Building a Cat Photo App'
|
||||
);
|
||||
await expect(progressBarContainer).toContainText(/\d% complete/);
|
||||
await page.getByRole('button', { name: 'Submit and continue' }).click();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Progress bar component in modal', () => {
|
||||
test.use({
|
||||
viewport: { width: 393, height: 851 },
|
||||
isMobile: true
|
||||
});
|
||||
test('should appear in the completion modal after user has submitted their code', async ({
|
||||
page,
|
||||
isMobile,
|
||||
browserName
|
||||
}) => {
|
||||
await page.goto(
|
||||
'/learn/javascript-algorithms-and-data-structures/basic-javascript/declare-javascript-variables'
|
||||
);
|
||||
await focusEditor({ page, isMobile });
|
||||
await clearEditor({ page, browserName });
|
||||
|
||||
await page.keyboard.insertText('var myName;');
|
||||
|
||||
if (isMobile) {
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Run',
|
||||
exact: false
|
||||
})
|
||||
.click();
|
||||
} else {
|
||||
await page
|
||||
.getByRole('button', { name: translations.buttons['check-code'] })
|
||||
.click();
|
||||
}
|
||||
|
||||
await expect(page.locator('.completion-block-meta')).toContainText(
|
||||
/\d% complete/
|
||||
);
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: 'Submit and go to next challenge' })
|
||||
.click();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { test, expect } from '@playwright/test';
|
||||
import translations from '../client/i18n/locales/english/translations.json';
|
||||
|
||||
const execP = promisify(exec);
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/settings');
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await execP('node ../tools/scripts/seed/seed-demo-user --certified-user');
|
||||
});
|
||||
|
||||
test.describe('Progress reset modal', () => {
|
||||
test('should display the content properly', async ({ page }) => {
|
||||
await page
|
||||
.getByRole('button', { name: 'Reset all of my progress' })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('dialog', { name: 'Reset My Progress' })
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText(
|
||||
'This will permanently delete and reset all of the following:'
|
||||
)
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText(
|
||||
'Your progress through each step/challenge (all completed challenges will be lost)'
|
||||
)
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText(
|
||||
'Any saved code, including partially completed challenges, and certification project code'
|
||||
)
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText('All completed and claimed certifications')
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText(
|
||||
'You will effectively be set back to the very first day you signed up.'
|
||||
)
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText(
|
||||
"We won't be able to recover any of it for you later, even if you change your mind."
|
||||
)
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: "Nevermind, I don't want to delete all of my progress"
|
||||
})
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: 'Reset everything. I want to start from the beginning'
|
||||
})
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: 'Reset everything. I want to start from the beginning'
|
||||
})
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
test('should close the dialog if the user clicks the cancel button', async ({
|
||||
page
|
||||
}) => {
|
||||
await page
|
||||
.getByRole('button', { name: 'Reset all of my progress' })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('dialog', { name: 'Reset My Progress' })
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: "Nevermind, I don't want to delete all of my progress"
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('dialog', { name: 'Reset My Progress' })
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
test('Reset progress button should be disabled if user incorrectly fills verify input text', async ({
|
||||
page
|
||||
}) => {
|
||||
await page
|
||||
.getByRole('button', { name: 'Reset all of my progress' })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('dialog', { name: 'Reset My Progress' })
|
||||
).toBeVisible();
|
||||
|
||||
const verifyResetInput = page.getByRole('textbox', {
|
||||
exact: true
|
||||
});
|
||||
await verifyResetInput.fill('incorrect text');
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: 'Reset everything. I want to start from the beginning'
|
||||
})
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
test('should reset the progress if the user fills the verify input text and clicks the reset button', async ({
|
||||
page
|
||||
}) => {
|
||||
await page
|
||||
.getByRole('button', { name: 'Reset all of my progress' })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('dialog', { name: 'Reset My Progress' })
|
||||
).toBeVisible();
|
||||
|
||||
const verifyResetText = translations.settings.danger['verify-reset-text'];
|
||||
|
||||
const verifyResetInput = page.getByRole('textbox', {
|
||||
exact: true
|
||||
});
|
||||
await verifyResetInput.fill(verifyResetText);
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Reset everything. I want to start from the beginning'
|
||||
})
|
||||
.click();
|
||||
|
||||
await page.waitForURL('/');
|
||||
await expect(page.getByText('Your progress has been reset')).toBeVisible();
|
||||
|
||||
// Go to /settings and confirm that all certifications are reset
|
||||
await page.goto('/settings');
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'Show Certification' })
|
||||
).toBeHidden();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
import translations from '../client/i18n/locales/english/translations.json';
|
||||
|
||||
test.describe('Should be shown automatically', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const urlWithProjectPreview =
|
||||
'/learn/2022/responsive-web-design/learn-html-by-building-a-cat-photo-app/step-1';
|
||||
await page.goto(urlWithProjectPreview);
|
||||
});
|
||||
|
||||
test('it should contain a non-empty preview frame', async ({ page }) => {
|
||||
const dialog = page.getByRole('dialog', {
|
||||
name: translations.learn['project-preview-title']
|
||||
});
|
||||
await expect(dialog).toBeVisible();
|
||||
const previewFrame = dialog.frameLocator('#fcc-project-preview-frame');
|
||||
await expect(
|
||||
// This is a part of the Cat Photo App that we expect to see. Essentially,
|
||||
// it's a proxy for "not empty"
|
||||
previewFrame.getByRole('heading', { name: 'Cat Photos' })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('it can be closed by the Start Coding! button', async ({ page }) => {
|
||||
const dialog = page.getByRole('dialog', {
|
||||
name: translations.learn['project-preview-title']
|
||||
});
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByTitle('CatPhotoApp preview')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Start Coding!' }).click();
|
||||
|
||||
await expect(dialog).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('it can be closed by the close button', async ({ page }) => {
|
||||
const dialog = page.getByRole('dialog', {
|
||||
name: translations.learn['project-preview-title']
|
||||
});
|
||||
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: translations.buttons.close })
|
||||
.click();
|
||||
|
||||
await expect(dialog).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Should be shown manually', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const urlWithProjectPreview =
|
||||
'/learn/javascript-v9/lab-drum-machine/build-drum-machine';
|
||||
await page.goto(urlWithProjectPreview);
|
||||
});
|
||||
|
||||
test("when the user clicks on 'this example project'", async ({ page }) => {
|
||||
const dialog = page.getByRole('dialog', {
|
||||
name: translations.learn['demo-project-title']
|
||||
});
|
||||
await expect(dialog).not.toBeVisible();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'this example project'
|
||||
})
|
||||
.click();
|
||||
await expect(dialog).toBeVisible();
|
||||
const previewFrame = dialog.frameLocator('#fcc-project-preview-frame');
|
||||
await expect(
|
||||
// This is a part of the Drum Machine that we expect to see. Essentially,
|
||||
// it's a proxy for "not empty"
|
||||
previewFrame.getByText('Power: On')
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Should not render', () => {
|
||||
test('on the second step of a project', async ({ page }) => {
|
||||
await page.goto(
|
||||
'/learn/2022/responsive-web-design/learn-html-by-building-a-cat-photo-app/step-2'
|
||||
);
|
||||
const dialog = page.getByRole('dialog', {
|
||||
name: translations.learn['project-preview-title']
|
||||
});
|
||||
await expect(dialog).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('on first step of a project without a preview', async ({ page }) => {
|
||||
await page.goto(
|
||||
'/learn/javascript-algorithms-and-data-structures-v8/learn-introductory-javascript-by-building-a-pyramid-generator/step-1'
|
||||
);
|
||||
const dialog = page.getByRole('dialog', {
|
||||
name: translations.learn['project-preview-title']
|
||||
});
|
||||
await expect(dialog).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,322 @@
|
||||
import { execSync } from 'child_process';
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import { SuperBlocks } from '@freecodecamp/shared/config/curriculum';
|
||||
import translations from '../client/i18n/locales/english/translations.json';
|
||||
import tributePage from './fixtures/tribute-page.json';
|
||||
import curriculum from './fixtures/js-ads-projects.json';
|
||||
import { authedRequest } from './utils/request';
|
||||
|
||||
import { focusEditor, getEditors, clearEditor } from './utils/editor';
|
||||
import { isMacOS } from './utils/user-agent';
|
||||
import { alertToBeVisible } from './utils/alerts';
|
||||
|
||||
interface Meta {
|
||||
challengeOrder: { id: string; title: string }[];
|
||||
}
|
||||
|
||||
interface File {
|
||||
contents: unknown;
|
||||
fileKey: string;
|
||||
}
|
||||
|
||||
type Solution = File[];
|
||||
|
||||
interface Challenge {
|
||||
title: string;
|
||||
block: string;
|
||||
superBlock: string;
|
||||
dashedName: string;
|
||||
solutions: Solution[];
|
||||
}
|
||||
|
||||
interface block {
|
||||
[key: string]: {
|
||||
meta: Meta;
|
||||
challenges: Challenge[];
|
||||
};
|
||||
}
|
||||
|
||||
const pythonProjects = {
|
||||
superBlock: SuperBlocks.MachineLearningPy,
|
||||
block: 'machine-learning-with-python-projects',
|
||||
challenges: [
|
||||
{
|
||||
slug: 'book-recommendation-engine-using-knn',
|
||||
nextChallengeText: 'Linear Regression Health Costs Calculator'
|
||||
},
|
||||
{
|
||||
slug: 'cat-and-dog-image-classifier',
|
||||
nextChallengeText: 'Book Recommendation Engine using KNN'
|
||||
},
|
||||
{
|
||||
slug: 'linear-regression-health-costs-calculator',
|
||||
nextChallengeText: 'Neural Network SMS Text Classifier'
|
||||
},
|
||||
{
|
||||
slug: 'neural-network-sms-text-classifier',
|
||||
nextChallengeText: 'Find the Symmetric Difference'
|
||||
},
|
||||
{
|
||||
slug: 'rock-paper-scissors',
|
||||
nextChallengeText: 'Cat and Dog Image Classifier'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const pasteContent = async (page: Page) => {
|
||||
if (isMacOS) {
|
||||
await page.keyboard.press('Meta+v');
|
||||
} else {
|
||||
await page.keyboard.press('Control+v');
|
||||
}
|
||||
};
|
||||
|
||||
test.use({ storageState: 'playwright/.auth/development-user.json' });
|
||||
|
||||
test.beforeAll(() => {
|
||||
execSync('node ../tools/scripts/seed/seed-demo-user');
|
||||
});
|
||||
|
||||
test.afterAll(() => {
|
||||
execSync('node ../tools/scripts/seed/seed-demo-user --certified-user');
|
||||
});
|
||||
|
||||
test.describe('Projects', () => {
|
||||
test('Should be possible to submit Python projects', async ({ page }) => {
|
||||
const { superBlock, block, challenges } = pythonProjects; // Ensure these are defined or imported
|
||||
|
||||
for (const { slug } of challenges) {
|
||||
const url = `/learn/${superBlock}/${block}/${slug}`;
|
||||
await page.goto(url);
|
||||
await page
|
||||
.getByLabel('Solution Link')
|
||||
.fill('https://replit.com/@camperbot/python-project#main.py');
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: "I've completed this challenge" })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Go to next challenge' })
|
||||
).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('JavaScript projects can be submitted and then viewed in /settings and on the certifications', () => {
|
||||
test.skip(
|
||||
({ browserName }) => browserName !== 'chromium',
|
||||
'Only chromium allows us to use the clipboard API.'
|
||||
);
|
||||
|
||||
test('projects are submitted and viewed correctly', async ({
|
||||
page,
|
||||
browserName,
|
||||
isMobile,
|
||||
request,
|
||||
context
|
||||
}) => {
|
||||
test.setTimeout(40000);
|
||||
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
|
||||
const block: block = curriculum;
|
||||
const targetBlock = 'javascript-algorithms-and-data-structures-projects';
|
||||
const javaScriptSuperBlock = block[targetBlock];
|
||||
|
||||
const { challenges, meta } = javaScriptSuperBlock || {
|
||||
challenges: [],
|
||||
meta: {}
|
||||
};
|
||||
|
||||
const projectTitles =
|
||||
meta.challengeOrder?.map(({ title }: { title: string }) => title) ?? [];
|
||||
|
||||
const projectsInOrder = projectTitles.map(title =>
|
||||
challenges.find(challenge => challenge.title === title)
|
||||
) as Challenge[];
|
||||
|
||||
const projectIdsInOrder = [
|
||||
'aaa48de84e1ecc7c742e1124',
|
||||
'a7f4d8f2483413a6ce226cac',
|
||||
'56533eb9ac21ba0edf2244e2',
|
||||
'aff0395860f5d3034dc0bfc9',
|
||||
'aa2e6f85cab2ab736c9a9b24'
|
||||
];
|
||||
|
||||
const contents = projectsInOrder[0].solutions[0][0].contents as string;
|
||||
|
||||
await page.goto(
|
||||
'/learn/javascript-algorithms-and-data-structures/javascript-algorithms-and-data-structures-projects/palindrome-checker'
|
||||
);
|
||||
|
||||
await focusEditor({ page, isMobile });
|
||||
await clearEditor({ page, browserName });
|
||||
|
||||
await page.evaluate(
|
||||
async contents => await navigator.clipboard.writeText(contents),
|
||||
contents
|
||||
);
|
||||
|
||||
await pasteContent(page);
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: translations.buttons['check-code'] })
|
||||
.click();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: translations.buttons['submit-continue'],
|
||||
exact: false
|
||||
})
|
||||
.click();
|
||||
|
||||
// Submit the rest with the API.
|
||||
const submissionPromises = [];
|
||||
|
||||
for (let i = 1; i < projectsInOrder.length; i++) {
|
||||
submissionPromises.push(
|
||||
authedRequest({
|
||||
request,
|
||||
method: 'post',
|
||||
endpoint: '/modern-challenge-completed',
|
||||
data: {
|
||||
id: projectIdsInOrder[i],
|
||||
challengeType: 5,
|
||||
files: projectsInOrder[i].solutions[0].map(({ contents }) => ({
|
||||
contents: contents,
|
||||
key: 'scriptjs',
|
||||
ext: 'js',
|
||||
name: 'script',
|
||||
history: ['script.js']
|
||||
}))
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(submissionPromises);
|
||||
|
||||
await page.goto('/settings');
|
||||
|
||||
for (const projectTitle of projectTitles) {
|
||||
await page
|
||||
.getByRole('button', { name: `View Solution for ${projectTitle}` })
|
||||
.click();
|
||||
const solutionModal = page.getByRole('dialog', {
|
||||
name: `Solution for ${projectTitle}`
|
||||
});
|
||||
await expect(solutionModal).toBeVisible();
|
||||
await solutionModal
|
||||
.getByRole('button', { name: translations.buttons['close'] })
|
||||
.first()
|
||||
.click();
|
||||
// Wait for the modal to disappear before continue
|
||||
await expect(solutionModal).toBeHidden();
|
||||
}
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: translations.buttons['agree-honesty']
|
||||
})
|
||||
.click();
|
||||
|
||||
await alertToBeVisible(page, translations.buttons['accepted-honesty']);
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Claim Certification Legacy JavaScript Algorithms and Data Structures V7'
|
||||
})
|
||||
.click();
|
||||
|
||||
await alertToBeVisible(
|
||||
page,
|
||||
'@developmentuser, you have successfully claimed the Legacy JavaScript Algorithms and Data Structures V7 Certification! Congratulations on behalf of the freeCodeCamp.org team!'
|
||||
);
|
||||
|
||||
const showCertLink = page.getByRole('link', {
|
||||
name: 'Show Certification Legacy JavaScript Algorithms and Data Structures V7'
|
||||
});
|
||||
await expect(showCertLink).toBeVisible();
|
||||
await expect(showCertLink).toHaveAttribute(
|
||||
'href',
|
||||
'/certification/developmentuser/javascript-algorithms-and-data-structures'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Submit button should be shown after submitting a project', () => {
|
||||
test.skip(
|
||||
({ browserName }) => browserName !== 'chromium',
|
||||
'Only chromium allows us to use the clipboard API.'
|
||||
);
|
||||
|
||||
test('Ctrl + enter triggers the submit button on multifile projects', async ({
|
||||
page,
|
||||
context,
|
||||
isMobile
|
||||
}) => {
|
||||
test.skip(isMobile);
|
||||
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
|
||||
const tributeContent = [
|
||||
tributePage.htmlFile.contents,
|
||||
tributePage.cssFile.contents
|
||||
];
|
||||
|
||||
await page.goto(
|
||||
'/learn/2022/responsive-web-design/build-a-tribute-page-project/build-a-tribute-page'
|
||||
);
|
||||
const editors = getEditors(page);
|
||||
await page.getByRole('button', { name: 'styles.css' }).click();
|
||||
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await page.evaluate(
|
||||
async contents => await navigator.clipboard.writeText(contents),
|
||||
tributeContent[i]
|
||||
);
|
||||
|
||||
await editors.nth(i).focus();
|
||||
await pasteContent(page);
|
||||
}
|
||||
|
||||
await page.keyboard.press('Control+Enter');
|
||||
await page
|
||||
.locator(
|
||||
'[data-playwright-test-label="independentLowerJaw-submit-button"]'
|
||||
)
|
||||
.click();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Should not be able to submit in quick succesion', () => {
|
||||
test('should not be possible to submit twice in quick succession', async ({
|
||||
page
|
||||
}) => {
|
||||
const { superBlock, block, challenges } = pythonProjects;
|
||||
const { slug } = challenges[0];
|
||||
|
||||
const url = `/learn/${superBlock}/${block}/${slug}`;
|
||||
await page.goto(url);
|
||||
|
||||
await page
|
||||
.getByLabel('Solution Link')
|
||||
.fill('https://replit.com/@camperbot/python-project#main.py');
|
||||
|
||||
const completedButton = page.getByRole('button', {
|
||||
name: "I've completed this challenge"
|
||||
});
|
||||
|
||||
await completedButton.click();
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
const submitChallenge = page.getByRole('button', {
|
||||
name: 'Go to next challenge',
|
||||
exact: false
|
||||
});
|
||||
await submitChallenge.click();
|
||||
|
||||
await expect(completedButton).toBeDisabled();
|
||||
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
import translations from '../client/i18n/locales/english/translations.json';
|
||||
import { allowTrailingSlash } from './utils/url';
|
||||
|
||||
const apiLocation = process.env.API_LOCATION || 'http://localhost:3000';
|
||||
|
||||
test.describe('Email sign-up page when user is not signed in', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
execSync('node ../tools/scripts/seed/seed-demo-user --certified-user');
|
||||
await page.goto('/email-sign-up');
|
||||
});
|
||||
|
||||
test('should display the content correctly', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
level: 1,
|
||||
name: translations.misc['email-signup']
|
||||
})
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(translations.misc['email-blast'])
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(translations.misc['email-signup-not-signed-in'])
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId('email-signup-sign-in-btn')).toBeVisible();
|
||||
});
|
||||
|
||||
test("should not enable Quincy's weekly newsletter when the user clicks the sign up button", async ({
|
||||
page
|
||||
}) => {
|
||||
await expect(page).toHaveTitle('Email Sign Up | freeCodeCamp.org');
|
||||
await expect(
|
||||
page.getByText(translations.misc['email-blast'])
|
||||
).toBeVisible();
|
||||
|
||||
const signupLink = page.getByTestId('email-signup-sign-in-btn');
|
||||
|
||||
await expect(signupLink).toBeVisible();
|
||||
await expect(signupLink).toHaveAttribute('href', `${apiLocation}/signin`);
|
||||
await signupLink.click();
|
||||
|
||||
// The user is signed in and automatically redirected to /learn after clicking the button.
|
||||
// We wait for this navigation to complete before moving onto the next.
|
||||
await page.waitForURL(allowTrailingSlash('/learn'));
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Welcome back, Full Stack User' })
|
||||
).toBeVisible();
|
||||
|
||||
await page.goto('/settings');
|
||||
await expect(
|
||||
page.getByRole('group', { name: translations.settings.email.weekly })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: translations.buttons['yes-please'] })
|
||||
).toHaveAttribute('aria-pressed', 'false');
|
||||
await expect(
|
||||
page.getByRole('button', { name: translations.buttons['no-thanks'] })
|
||||
).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Email sign-up page when user is signed in', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// It's necessary to seed with a user that has not selected an email newsletter option.
|
||||
execSync('node ../tools/scripts/seed/seed-demo-user --certified-user');
|
||||
|
||||
await page.goto('/email-sign-up');
|
||||
});
|
||||
|
||||
test('should display the content correctly', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByText(translations.misc['email-signup'])
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(translations.misc['email-blast'])
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(translations.misc['quincy'])).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: translations.buttons['yes-please'] })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: translations.buttons['no-thanks'] })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("should disable Quincy's weekly newsletter if the user clicks No", async ({
|
||||
page
|
||||
}) => {
|
||||
await expect(page).toHaveTitle('Email Sign Up | freeCodeCamp.org');
|
||||
await expect(
|
||||
page.getByText(translations.misc['email-blast'])
|
||||
).toBeVisible();
|
||||
|
||||
const noThanksButton = page.getByRole('button', {
|
||||
name: translations.buttons['no-thanks']
|
||||
});
|
||||
|
||||
await expect(noThanksButton).toBeVisible();
|
||||
await noThanksButton.click();
|
||||
|
||||
// The user is signed in and automatically redirected to /learn after clicking the button.
|
||||
// We wait for the navigation to complete.
|
||||
await page.waitForURL('/learn');
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Welcome back, Full Stack User' })
|
||||
).toBeVisible();
|
||||
|
||||
await page.goto('/settings');
|
||||
await expect(
|
||||
page.getByRole('group', { name: translations.settings.email.weekly })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: translations.buttons['no-thanks'] })
|
||||
).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(
|
||||
page.getByRole('button', { name: translations.buttons['yes-please'] })
|
||||
).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
test("should enable Quincy's weekly newsletter if the user clicks Yes", async ({
|
||||
page
|
||||
}) => {
|
||||
await expect(page).toHaveTitle('Email Sign Up | freeCodeCamp.org');
|
||||
await expect(
|
||||
page.getByText(translations.misc['email-blast'])
|
||||
).toBeVisible();
|
||||
|
||||
const signupButton = page.getByRole('button', {
|
||||
name: translations.buttons['yes-please']
|
||||
});
|
||||
|
||||
await expect(signupButton).toBeVisible();
|
||||
await signupButton.click();
|
||||
|
||||
// The user is signed in and automatically redirected to /learn after clicking the button.
|
||||
// We wait for the navigation to complete.
|
||||
await page.waitForURL('/learn');
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Welcome back, Full Stack User' })
|
||||
).toBeVisible();
|
||||
|
||||
// `sendQuincyEmail` is not set in the DB since the endpoint is mocked,
|
||||
// so we are overriding the user data once again to mimic the real behavior.
|
||||
await page.route('*/**/user/session-user', async route => {
|
||||
const response = await route.fetch();
|
||||
const json = await response.json();
|
||||
|
||||
json.user.certifieduser.sendQuincyEmail = true;
|
||||
await route.fulfill({ json });
|
||||
});
|
||||
|
||||
await page.goto('/settings');
|
||||
await expect(
|
||||
page.getByRole('group', { name: translations.settings.email.weekly })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: translations.buttons['yes-please'] })
|
||||
).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(
|
||||
page.getByRole('button', { name: translations.buttons['no-thanks'] })
|
||||
).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,334 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { allowTrailingSlash } from './utils/url';
|
||||
|
||||
interface QuizQuestion {
|
||||
distractors: string[];
|
||||
text: string;
|
||||
answer: string;
|
||||
}
|
||||
|
||||
interface Quiz {
|
||||
questions: QuizQuestion[];
|
||||
}
|
||||
|
||||
interface PageData {
|
||||
result: {
|
||||
data: {
|
||||
challengeNode: {
|
||||
challenge: { quizzes: Quiz[] };
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const quizPath =
|
||||
'/learn/responsive-web-design-v9/quiz-basic-html/quiz-basic-html';
|
||||
|
||||
test.describe('Quiz challenge', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const fixturePath = path.join(__dirname, 'fixtures', 'quiz-fixture.json');
|
||||
const fixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8')) as Quiz[];
|
||||
|
||||
// Intercept Gatsby page-data and inject a deterministic quiz fixture
|
||||
await page.route(`**/page-data${quizPath}/page-data.json`, async route => {
|
||||
const response = await route.fetch();
|
||||
const body = await response.text();
|
||||
|
||||
const pageData = JSON.parse(body) as PageData;
|
||||
pageData.result.data.challengeNode.challenge.quizzes = fixture;
|
||||
|
||||
await route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(pageData)
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(quizPath);
|
||||
});
|
||||
|
||||
test('should display a list of unanswered questions if user has not answered all questions', async ({
|
||||
page
|
||||
}) => {
|
||||
// Wait for the page content to render
|
||||
await expect(page.getByRole('radiogroup')).toHaveCount(20);
|
||||
const radioGroups = await page.getByRole('radiogroup').all();
|
||||
|
||||
// The radio label contents are rendered with `PrismFormatted`.
|
||||
// For accessibility we must ensure:
|
||||
// - The formatted content is wrapped in a `span`
|
||||
// - No ARIA role is added to `pre` elements
|
||||
const firstGroup = radioGroups[0];
|
||||
const spanLabel = firstGroup.locator('span.quiz-answer-label');
|
||||
await expect(spanLabel).toHaveCount(4);
|
||||
|
||||
const preWithRole = firstGroup.locator('pre[role]');
|
||||
await expect(preWithRole).toHaveCount(0);
|
||||
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const radioGroups = await page.getByRole('radiogroup').all();
|
||||
await radioGroups[i].getByRole('radio').first().click();
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Finish the quiz' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText(
|
||||
'The following questions are unanswered: 16, 17, 18, 19, 20. You must answer all questions.'
|
||||
)
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should allow user to complete the block when they pass the quiz', async ({
|
||||
page
|
||||
}) => {
|
||||
test.setTimeout(20000);
|
||||
|
||||
// Wait for the page content to render
|
||||
await expect(page.getByRole('radiogroup')).toHaveCount(20);
|
||||
|
||||
const radioGroups = await page.getByRole('radiogroup').all();
|
||||
|
||||
// Answer 18 questions correctly.
|
||||
// This is enough to pass the quiz, and also allowing us to test the quiz passing criteria.
|
||||
for (let i = 0; i < radioGroups.length; i++) {
|
||||
const group = radioGroups[i];
|
||||
const correct = group.getByRole('radio', { name: /Correct answer/i });
|
||||
const wrong = group.getByRole('radio', { name: /Wrong 1/i });
|
||||
if (i <= 17) {
|
||||
await correct.click();
|
||||
} else {
|
||||
await wrong.click();
|
||||
}
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Finish the quiz' }).click();
|
||||
await page.getByRole('button', { name: 'Yes, I am finished' }).click();
|
||||
|
||||
// Wait for the finish quiz modal to close
|
||||
await expect(
|
||||
page.getByRole('dialog', { name: 'Finish Quiz' })
|
||||
).toBeHidden();
|
||||
|
||||
// Check that the completion modal shows up
|
||||
await expect(
|
||||
page
|
||||
.getByRole('dialog')
|
||||
.filter({ has: page.getByRole('button', { name: /submit and go/i }) })
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('button', { name: /close/i })
|
||||
.click();
|
||||
|
||||
// Wait for the completion modal to close
|
||||
await expect(page.getByRole('dialog')).toBeHidden();
|
||||
|
||||
// If the user closes the modal without submitting,
|
||||
// the finish quiz button is replaced by the submit one.
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Finish the quiz' })
|
||||
).toBeHidden();
|
||||
await expect(
|
||||
page.getByRole('button', { name: /submit and go/i })
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText('You have 18 out of 20 questions correct.')
|
||||
).toBeVisible();
|
||||
|
||||
// Confirm that all options are disabled.
|
||||
// We do this by finding all of the disabled radio elements on the page,
|
||||
// and check if the count matches the total number of quiz answers (4 answers x 20 questions).
|
||||
// This approach is much faster than querying each radio on the page and check if they are disabled.
|
||||
await expect(
|
||||
page.locator("[role='radio'][aria-disabled='true']")
|
||||
).toHaveCount(4 * 20);
|
||||
});
|
||||
|
||||
test("should not allow user to complete the block when they don't pass the quiz", async ({
|
||||
page
|
||||
}) => {
|
||||
test.setTimeout(20000);
|
||||
|
||||
// Wait for the page content to render
|
||||
await expect(page.getByRole('radiogroup')).toHaveCount(20);
|
||||
|
||||
const radioGroups = await page.getByRole('radiogroup').all();
|
||||
|
||||
// Answer only 10 questions correctly.
|
||||
for (let i = 0; i < radioGroups.length; i++) {
|
||||
const group = radioGroups[i];
|
||||
const correct = group.getByRole('radio', { name: /Correct answer/i });
|
||||
const wrong = group.getByRole('radio', { name: /Wrong 1/i });
|
||||
if (i <= 9) {
|
||||
await correct.click();
|
||||
} else {
|
||||
await wrong.click();
|
||||
}
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Finish the quiz' }).click();
|
||||
await page.getByRole('button', { name: 'Yes, I am finished' }).click();
|
||||
|
||||
// Wait for the finish quiz modal to close
|
||||
await expect(
|
||||
page.getByRole('dialog', { name: 'Finish Quiz' })
|
||||
).toBeHidden();
|
||||
|
||||
await expect(
|
||||
page.getByText('You have 10 out of 20 questions correct.')
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Finish the quiz' })
|
||||
).toBeDisabled();
|
||||
|
||||
// Confirm that all options are disabled.
|
||||
// We do this by finding all of the disabled radio elements on the page,
|
||||
// and check if the count matches the total number of quiz answers (4 answers x 20 questions).
|
||||
// This approach is much faster than querying each radio on the page and check if they are disabled.
|
||||
await expect(
|
||||
page.locator("[role='radio'][aria-disabled='true']")
|
||||
).toHaveCount(4 * 20);
|
||||
});
|
||||
|
||||
test('should show a confirm exit modal when user clicks on the exit button', async ({
|
||||
page
|
||||
}) => {
|
||||
// Wait for the page content to render
|
||||
await expect(page.getByRole('radiogroup')).toHaveCount(20);
|
||||
|
||||
await page.getByRole('button', { name: 'Exit the quiz' }).click();
|
||||
|
||||
// The navigation should be blocked, the user should stay on the same page
|
||||
await expect(page).toHaveURL(
|
||||
allowTrailingSlash(
|
||||
'/learn/responsive-web-design-v9/quiz-basic-html/quiz-basic-html'
|
||||
)
|
||||
);
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await expect(page.getByRole('dialog', { name: 'Exit Quiz' })).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: 'No, I would like to continue the quiz' })
|
||||
.click();
|
||||
await expect(page.getByRole('dialog', { name: 'Exit Quiz' })).toBeHidden();
|
||||
|
||||
await page.getByRole('button', { name: 'Exit the quiz' }).click();
|
||||
await expect(page.getByRole('dialog', { name: 'Exit Quiz' })).toBeVisible();
|
||||
await page
|
||||
.getByRole('button', { name: 'Yes, I want to leave the quiz' })
|
||||
.click();
|
||||
|
||||
await page.waitForURL('/learn/responsive-web-design-v9/#quiz-basic-html');
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 3, name: 'Basic HTML Quiz' })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show a confirm exit modal when user clicks on a link', async ({
|
||||
page
|
||||
}) => {
|
||||
// Wait for the page content to render
|
||||
await expect(page.getByRole('radiogroup')).toHaveCount(20);
|
||||
|
||||
// navigate to /learn
|
||||
await page.getByTestId('header-universal-nav-logo').click();
|
||||
|
||||
await expect(page.getByRole('dialog', { name: 'Exit Quiz' })).toBeVisible();
|
||||
await page
|
||||
.getByRole('button', { name: 'Yes, I want to leave the quiz' })
|
||||
.click();
|
||||
|
||||
await expect(page).toHaveURL(allowTrailingSlash('/learn'));
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Welcome back, Full Stack User.' })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show a confirm exit modal when user presses the back button', async ({
|
||||
page
|
||||
}) => {
|
||||
const blockPath = '/learn/responsive-web-design-v9/#quiz-basic-html';
|
||||
|
||||
await page.goto(blockPath);
|
||||
await page.goto(quizPath);
|
||||
|
||||
await expect(page.getByRole('radiogroup')).toHaveCount(20);
|
||||
|
||||
await page.goBack();
|
||||
|
||||
await expect(page).toHaveURL(allowTrailingSlash(quizPath));
|
||||
await expect(page.getByRole('dialog', { name: 'Exit Quiz' })).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: 'Yes, I want to leave the quiz' })
|
||||
.click();
|
||||
|
||||
await page.waitForURL(blockPath);
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 3, name: 'Basic HTML Quiz' })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show a confirm exit modal when user closes the page', async ({
|
||||
page
|
||||
}) => {
|
||||
// Wait for the page content to render
|
||||
await expect(page.getByRole('radiogroup')).toHaveCount(20);
|
||||
|
||||
page.on('dialog', async dialog => {
|
||||
expect(dialog.type()).toBe('beforeunload');
|
||||
await dialog.dismiss();
|
||||
});
|
||||
|
||||
await page.close({ runBeforeUnload: true });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Quiz with audio question', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const fixturePath = path.join(
|
||||
__dirname,
|
||||
'fixtures',
|
||||
'quiz-audio-fixture.json'
|
||||
);
|
||||
const fixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8')) as Quiz[];
|
||||
|
||||
// Intercept the exact page-data.json for the quiz and inject the fixture
|
||||
await page.route(`**/page-data${quizPath}/page-data.json`, async route => {
|
||||
const response = await route.fetch();
|
||||
const body = await response.text();
|
||||
|
||||
const pageData = JSON.parse(body) as PageData;
|
||||
pageData.result.data.challengeNode.challenge.quizzes = fixture;
|
||||
|
||||
await route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(pageData)
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(quizPath);
|
||||
});
|
||||
|
||||
test('renders audio player and transcript when question has audio', async ({
|
||||
page
|
||||
}) => {
|
||||
await expect(page.getByRole('radiogroup')).toHaveCount(1);
|
||||
|
||||
const audio = page.locator('audio');
|
||||
await expect(audio).toHaveCount(1);
|
||||
await expect(audio).toHaveAttribute(
|
||||
'src',
|
||||
'https://cdn.freecodecamp.org/curriculum/english/animation-assets/sounds/test-audio.mp3'
|
||||
);
|
||||
|
||||
await page.getByText(/transcript/i).click();
|
||||
await expect(page.getByText('Speaker: Hello world')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { allowTrailingSlash } from './utils/url';
|
||||
|
||||
// To run this test locally you will need to run: pnpm run start-ci;
|
||||
// Also, make sure that you have pm2 installed globally via: pnpm install -g pm2
|
||||
|
||||
const pathsToTest = [
|
||||
['/challenges', '/learn'],
|
||||
['/learn/front-end-libraries', 'learn/front-end-development-libraries'],
|
||||
[
|
||||
'/learn/front-end-libraries/bootstrap',
|
||||
'/learn/front-end-development-libraries/bootstrap'
|
||||
],
|
||||
[
|
||||
'/learn/front-end-libraries/front-end-libraries-projects',
|
||||
'/learn/front-end-development-libraries/front-end-development-libraries-projects'
|
||||
],
|
||||
[
|
||||
'/learn/front-end-libraries/front-end-libraries-projects/build-a-random-quote-machine',
|
||||
'/learn/front-end-development-libraries/front-end-development-libraries-projects/build-a-random-quote-machine'
|
||||
],
|
||||
[
|
||||
'/certification/certifieduser/front-end-libraries',
|
||||
'/certification/certifieduser/front-end-development-libraries'
|
||||
],
|
||||
[
|
||||
'/learn/front-end-libraries/bootstrap/use-responsive-design-with-bootstrap-fluid-containers',
|
||||
'/learn/front-end-development-libraries/bootstrap/use-responsive-design-with-bootstrap-fluid-containers'
|
||||
],
|
||||
['/learn/apis-and-microservices', 'learn/back-end-development-and-apis'],
|
||||
[
|
||||
'/learn/apis-and-microservices/managing-packages-with-npm',
|
||||
'/learn/back-end-development-and-apis/managing-packages-with-npm'
|
||||
],
|
||||
[
|
||||
'/learn/apis-and-microservices/managing-packages-with-npm/how-to-use-package-json-the-core-of-any-node-js-project-or-npm-package',
|
||||
'/learn/back-end-development-and-apis/managing-packages-with-npm/how-to-use-package-json-the-core-of-any-node-js-project-or-npm-package'
|
||||
],
|
||||
[
|
||||
'/learn/apis-and-microservices/apis-and-microservices-projects',
|
||||
'/learn/back-end-development-and-apis/back-end-development-and-apis-projects'
|
||||
],
|
||||
[
|
||||
'/learn/apis-and-microservices/apis-and-microservices-projects/timestamp-microservice',
|
||||
'/learn/back-end-development-and-apis/back-end-development-and-apis-projects/timestamp-microservice'
|
||||
],
|
||||
[
|
||||
'/certification/certifieduser/apis-and-microservices',
|
||||
'/certification/certifieduser/back-end-development-and-apis'
|
||||
],
|
||||
[
|
||||
'/learn/responsive-web-design/applied-visual-design/adjust-the-size-of-a-header-versus-a-paragraph-tag',
|
||||
'/learn/responsive-web-design/applied-visual-design/adjust-the-size-of-a-heading-element-versus-a-paragraph-element'
|
||||
],
|
||||
[
|
||||
'/learn/javascript-algorithms-and-data-structures/es6/explore-differences-between-the-var-and-let-keywords',
|
||||
'/learn/javascript-algorithms-and-data-structures/basic-javascript/explore-differences-between-the-var-and-let-keywords'
|
||||
],
|
||||
[
|
||||
'/learn/javascript-algorithms-and-data-structures/es6/declare-a-read-only-variable-with-the-const-keyword',
|
||||
'/learn/javascript-algorithms-and-data-structures/basic-javascript/declare-a-read-only-variable-with-the-const-keyword'
|
||||
],
|
||||
['/challenges/responsive-web-design', '/learn/responsive-web-design'],
|
||||
[
|
||||
'/challenges/responsive-web-design/basic-html-and-html5',
|
||||
'/learn/responsive-web-design/basic-html-and-html5'
|
||||
],
|
||||
[
|
||||
'/challenges/responsive-web-design/basic-html-and-html5/say-hello-to-html-elements',
|
||||
'/learn/responsive-web-design/basic-html-and-html5/say-hello-to-html-elements'
|
||||
]
|
||||
];
|
||||
|
||||
test.describe('Legacy Challenge Path Redirection Tests', () => {
|
||||
for (const [input, expected] of pathsToTest) {
|
||||
test(`should redirect from ${input} to ${expected}`, async ({ page }) => {
|
||||
await page.goto(input);
|
||||
await expect(page).toHaveURL(allowTrailingSlash(expected));
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
deleteAllEmails,
|
||||
getAllEmails,
|
||||
getFirstEmail,
|
||||
getSubject
|
||||
} from './utils/email';
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await deleteAllEmails();
|
||||
});
|
||||
|
||||
test('should be possible to report a user from their profile page', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/twaha');
|
||||
|
||||
await page.getByText("Flag This User's Account for Abuse").click();
|
||||
|
||||
await expect(
|
||||
page.getByText("Do you want to report twaha's portfolio for abuse?")
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByRole('textbox', { name: 'What would you like to report?' })
|
||||
.fill('Some details');
|
||||
await page.getByRole('button', { name: 'Submit the report' }).click();
|
||||
await expect(page).toHaveURL('/learn');
|
||||
|
||||
await expect(page.getByTestId('flash-message')).toBeVisible();
|
||||
await expect(page.getByTestId('flash-message')).toContainText(
|
||||
'A report was sent to the team with foo@bar.com in copy'
|
||||
);
|
||||
|
||||
await expect(async () => {
|
||||
const emails = await getAllEmails();
|
||||
expect(emails.messages).toHaveLength(1);
|
||||
expect(getSubject(getFirstEmail(emails))).toBe(
|
||||
"Abuse Report : Reporting twaha's profile."
|
||||
);
|
||||
}).toPass();
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Reset Editor Layout', () => {
|
||||
test('drag layout and reset', async ({ page, isMobile }) => {
|
||||
test.skip(
|
||||
isMobile,
|
||||
'The mobile layout does not have resizable panes, so this test is not applicable.'
|
||||
);
|
||||
|
||||
await page.goto(
|
||||
'/learn/2022/responsive-web-design/learn-basic-css-by-building-a-cafe-menu/step-15'
|
||||
);
|
||||
|
||||
const desktopLayout = page.getByTestId('desktop-layout');
|
||||
const splitter = page.getByTestId('preview-left-splitter');
|
||||
const editorPane = desktopLayout.getByTestId('editor-pane');
|
||||
const initialStyle = await editorPane.getAttribute('style');
|
||||
|
||||
expect(initialStyle).toContain('flex: 1');
|
||||
|
||||
// Drag the splitter to resize the editor pane
|
||||
await splitter.hover();
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(100, 100);
|
||||
await page.mouse.up();
|
||||
|
||||
const newStyle = await editorPane.getAttribute('style');
|
||||
expect(newStyle).not.toContain('flex: 1');
|
||||
|
||||
await page.goto('/settings#privacy-settings');
|
||||
|
||||
const resetButton = page.getByTestId('reset-layout-btn');
|
||||
await resetButton.click();
|
||||
|
||||
await expect(page.getByTestId('flash-message')).toContainText(
|
||||
'Your editor layout has been reset'
|
||||
);
|
||||
|
||||
await expect(resetButton).toBeDisabled();
|
||||
|
||||
await page.goto(
|
||||
'/learn/2022/responsive-web-design/learn-basic-css-by-building-a-cafe-menu/step-15'
|
||||
);
|
||||
|
||||
const afterReset = await editorPane.getAttribute('style');
|
||||
expect(afterReset).toContain('flex: 1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Sass Challenge', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(
|
||||
'/learn/front-end-development-libraries/sass/use-for-to-create-a-sass-loop'
|
||||
);
|
||||
});
|
||||
|
||||
test('should render the sass preview', async ({ page, isMobile }) => {
|
||||
if (isMobile) {
|
||||
await page.getByRole('tab', { name: 'Preview' }).click();
|
||||
}
|
||||
|
||||
const frame = page.frameLocator('.challenge-preview iframe');
|
||||
expect(frame).not.toBeNull();
|
||||
|
||||
await expect(frame.locator('.text-1')).toBeVisible();
|
||||
await expect(frame.locator('.text-2')).toBeVisible();
|
||||
await expect(frame.locator('.text-3')).toBeVisible();
|
||||
await expect(frame.locator('.text-4')).toBeVisible();
|
||||
await expect(frame.locator('.text-5')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Editor scrollbar width on mobile', () => {
|
||||
test.use({
|
||||
viewport: { width: 393, height: 851 }
|
||||
});
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/settings');
|
||||
});
|
||||
|
||||
test('Default editor scrollbar width should be 5px', async ({ page }) => {
|
||||
await expect(page.locator('#scrollbar-width-slider')).toHaveValue('5');
|
||||
await page.goto(
|
||||
'/learn/2022/responsive-web-design/learn-html-by-building-a-cat-photo-app/step-2'
|
||||
);
|
||||
|
||||
const upperJawElement = page.locator('.editor-upper-jaw');
|
||||
const upperJawWidth = await upperJawElement.evaluate(
|
||||
(node: HTMLElement) => node.offsetWidth
|
||||
);
|
||||
|
||||
const lowerJawElement = page.locator('#editor-lower-jaw');
|
||||
expect(
|
||||
await lowerJawElement.evaluate((node: HTMLElement) => node.offsetWidth)
|
||||
).toEqual(upperJawWidth);
|
||||
|
||||
const scrollableElement = page.locator('.monaco-scrollable-element');
|
||||
expect(
|
||||
await scrollableElement.evaluate(
|
||||
(node: HTMLElement) => node.offsetWidth - 5
|
||||
)
|
||||
).toEqual(upperJawWidth);
|
||||
});
|
||||
|
||||
test('Should allow you to change editor scrollbar width to 25px', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.locator('.scrollbar-width-numbers > [data-value="25"]').click();
|
||||
await expect(page.locator('#scrollbar-width-slider')).toHaveValue('25');
|
||||
await page.goto(
|
||||
'/learn/2022/responsive-web-design/learn-html-by-building-a-cat-photo-app/step-2'
|
||||
);
|
||||
|
||||
const upperJawElement = page.locator('.editor-upper-jaw');
|
||||
const upperJawWidth = await upperJawElement.evaluate(
|
||||
(node: HTMLElement) => node.offsetWidth
|
||||
);
|
||||
|
||||
const lowerJawElement = page.locator('#editor-lower-jaw');
|
||||
expect(
|
||||
await lowerJawElement.evaluate((node: HTMLElement) => node.offsetWidth)
|
||||
).toEqual(upperJawWidth);
|
||||
|
||||
const scrollableElement = page.locator('.monaco-scrollable-element');
|
||||
expect(
|
||||
await scrollableElement.evaluate(
|
||||
(node: HTMLElement) => node.offsetWidth - 25
|
||||
)
|
||||
).toEqual(upperJawWidth);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import translations from '../client/i18n/locales/english/translations.json';
|
||||
|
||||
const getSearchInput = async ({
|
||||
page,
|
||||
isMobile
|
||||
}: {
|
||||
page: Page;
|
||||
isMobile: boolean;
|
||||
}) => {
|
||||
if (isMobile) {
|
||||
const menuButton = page.getByRole('button', {
|
||||
name: translations.buttons.menu
|
||||
});
|
||||
await expect(menuButton).toBeVisible();
|
||||
await menuButton.click();
|
||||
}
|
||||
|
||||
return page.getByLabel('Search');
|
||||
};
|
||||
|
||||
test.describe('Search bar optimized', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test('should display correct placeholder', async ({ page, isMobile }) => {
|
||||
const searchInput = await getSearchInput({ page, isMobile });
|
||||
|
||||
await expect(searchInput).toBeVisible();
|
||||
// Because we're mocking Algolia requests, the placeholder
|
||||
// should be the default one.
|
||||
await expect(searchInput).toHaveAttribute(
|
||||
'placeholder',
|
||||
translations.search.placeholder.default
|
||||
);
|
||||
});
|
||||
|
||||
test('should return the search results when the user presses Enter', async ({
|
||||
context,
|
||||
page,
|
||||
isMobile
|
||||
}) => {
|
||||
const searchInput = await getSearchInput({ page, isMobile });
|
||||
await expect(searchInput).toBeVisible();
|
||||
|
||||
await searchInput.fill('test');
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
// Wait for the new page to load.
|
||||
const newPage = await context.waitForEvent('page');
|
||||
await newPage.waitForLoadState();
|
||||
|
||||
expect(newPage.url()).toBe(
|
||||
'https://www.freecodecamp.org/news/search/?query=test'
|
||||
);
|
||||
});
|
||||
|
||||
test('should return the search results when the user clicks the search button', async ({
|
||||
context,
|
||||
page,
|
||||
isMobile
|
||||
}) => {
|
||||
const searchInput = await getSearchInput({ page, isMobile });
|
||||
await expect(searchInput).toBeVisible();
|
||||
|
||||
await searchInput.fill('test');
|
||||
await page
|
||||
.getByRole('button', { name: translations.icons.magnifier })
|
||||
.click();
|
||||
|
||||
// Wait for the new page to load.
|
||||
const newPage = await context.waitForEvent('page');
|
||||
await newPage.waitForLoadState();
|
||||
|
||||
expect(newPage.url()).toBe(
|
||||
'https://www.freecodecamp.org/news/search/?query=test'
|
||||
);
|
||||
});
|
||||
|
||||
test('should clear the search input when the user clicks the clear button', async ({
|
||||
page,
|
||||
isMobile
|
||||
}) => {
|
||||
const searchInput = await getSearchInput({ page, isMobile });
|
||||
await expect(searchInput).toBeVisible();
|
||||
|
||||
await searchInput.fill('test');
|
||||
await page
|
||||
.getByRole('button', { name: translations.icons['input-reset'] })
|
||||
.click();
|
||||
|
||||
await expect(searchInput).toHaveValue('');
|
||||
});
|
||||
|
||||
test('The optimized searchbar component should not render when not on the landing page', async ({
|
||||
page,
|
||||
isMobile
|
||||
}) => {
|
||||
// This means that the default search bar should be rendered ^.
|
||||
await page.locator('[data-testid="curriculum-map-button"]').nth(0).click();
|
||||
|
||||
if (isMobile) {
|
||||
const menuButton = page.getByTestId('header-menu-button');
|
||||
await expect(menuButton).toBeVisible();
|
||||
await menuButton.click();
|
||||
}
|
||||
|
||||
await expect(page.getByTestId('header-search')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import translations from '../client/i18n/locales/english/translations.json';
|
||||
import algoliaEightHits from './fixtures/algolia-eight-hits.json';
|
||||
import algoliaFiveHits from './fixtures/algolia-five-hits.json';
|
||||
import algoliaNoHits from './fixtures/algolia-no-hits.json';
|
||||
|
||||
const haveApiKeys =
|
||||
process.env.ALGOLIA_APP_ID !== 'app_id_from_algolia_dashboard' &&
|
||||
process.env.ALGOLIA_API_KEY !== 'api_key_from_algolia_dashboard';
|
||||
|
||||
const getSearchInput = async ({
|
||||
page,
|
||||
isMobile
|
||||
}: {
|
||||
page: Page;
|
||||
isMobile: boolean;
|
||||
}) => {
|
||||
if (isMobile) {
|
||||
const menuButton = page.getByRole('button', {
|
||||
name: translations.buttons.menu
|
||||
});
|
||||
await expect(menuButton).toBeVisible();
|
||||
await menuButton.click();
|
||||
}
|
||||
|
||||
return page.getByLabel('Search', { exact: true });
|
||||
};
|
||||
|
||||
const search = async ({
|
||||
page,
|
||||
isMobile,
|
||||
query
|
||||
}: {
|
||||
page: Page;
|
||||
isMobile: boolean;
|
||||
query: string;
|
||||
}) => {
|
||||
const searchInput = await getSearchInput({ page, isMobile });
|
||||
await searchInput.fill(query);
|
||||
};
|
||||
|
||||
// Mock Algolia requests to prevent hitting Algolia server unnecessarily.
|
||||
// Comment out the function call if you want to test against the real server.
|
||||
const mockAlgolia = async ({
|
||||
page,
|
||||
hitsPerPage
|
||||
}: {
|
||||
page: Page;
|
||||
hitsPerPage: number;
|
||||
}) => {
|
||||
if (hitsPerPage === 8) {
|
||||
await page.route(/dsn.algolia.net/, async route => {
|
||||
await route.fulfill({ json: algoliaEightHits });
|
||||
});
|
||||
} else if (hitsPerPage === 5) {
|
||||
await page.route(/dsn.algolia.net/, async route => {
|
||||
await route.fulfill({ json: algoliaFiveHits });
|
||||
});
|
||||
} else if (hitsPerPage === 0) {
|
||||
await page.route(/dsn.algolia.net/, async route => {
|
||||
await route.fulfill({ json: algoliaNoHits });
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
test.describe('Search bar', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/learn');
|
||||
});
|
||||
|
||||
test('should display correctly', async ({ page, isMobile }) => {
|
||||
const searchInput = await getSearchInput({ page, isMobile });
|
||||
|
||||
await expect(searchInput).toBeVisible();
|
||||
|
||||
await expect(searchInput).toHaveAttribute('placeholder', /Search/i);
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Submit search terms' })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should return the search results when the user presses Enter', async ({
|
||||
page,
|
||||
isMobile
|
||||
}) => {
|
||||
test.skip(!haveApiKeys, 'This test requires Algolia API keys');
|
||||
await mockAlgolia({ page, hitsPerPage: 8 });
|
||||
await search({ page, isMobile, query: 'article' });
|
||||
|
||||
// Wait for the search results to show up
|
||||
const resultList = page.getByRole('list', { name: 'Search results' });
|
||||
// Initially, the dropdown contains an `li` with the text "No tutorials found",
|
||||
// so we need to check the text content to ensure the correct `li` is displayed.
|
||||
await expect(resultList.getByRole('listitem').first()).toContainText(
|
||||
/article/i
|
||||
);
|
||||
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
await page.waitForURL(
|
||||
'https://www.freecodecamp.org/news/search/?query=article'
|
||||
);
|
||||
const title = await page.title();
|
||||
expect(title).toBe('Search - freeCodeCamp.org');
|
||||
});
|
||||
|
||||
test('should return the search results when the user clicks the search button', async ({
|
||||
page,
|
||||
isMobile
|
||||
}) => {
|
||||
test.skip(!haveApiKeys, 'This test requires Algolia API keys');
|
||||
await mockAlgolia({ page, hitsPerPage: 8 });
|
||||
await search({ page, isMobile, query: 'article' });
|
||||
|
||||
// Wait for the search results to show up
|
||||
const resultList = page.getByRole('list', { name: 'Search results' });
|
||||
// Initially, the dropdown contains an `li` with the text "No tutorials found",
|
||||
// so we need to check the text content to ensure the correct `li` is displayed.
|
||||
await expect(resultList.getByRole('listitem').first()).toContainText(
|
||||
/article/i
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: 'Submit search terms' }).click();
|
||||
|
||||
await page.waitForURL(
|
||||
'https://www.freecodecamp.org/news/search/?query=article'
|
||||
);
|
||||
const title = await page.title();
|
||||
expect(title).toBe('Search - freeCodeCamp.org');
|
||||
});
|
||||
|
||||
test('should show an empty result list if no results found', async ({
|
||||
page,
|
||||
isMobile
|
||||
}) => {
|
||||
await mockAlgolia({ page, hitsPerPage: 0 });
|
||||
await search({ page, isMobile, query: 'test' });
|
||||
|
||||
const resultList = page.getByRole('list', { name: 'Search results' });
|
||||
await expect(resultList.getByRole('listitem')).toHaveCount(1);
|
||||
await expect(resultList.getByRole('listitem')).toHaveText(
|
||||
'No results found'
|
||||
);
|
||||
});
|
||||
|
||||
test('should clear the input and hide the result dropdown when the user clicks the clear button', async ({
|
||||
page,
|
||||
isMobile
|
||||
}) => {
|
||||
const searchInput = await getSearchInput({ page, isMobile });
|
||||
await expect(searchInput).toBeVisible();
|
||||
|
||||
await searchInput.fill('test');
|
||||
await page.getByRole('button', { name: 'Clear search terms' }).click();
|
||||
|
||||
await expect(searchInput).toHaveValue('');
|
||||
await expect(
|
||||
page.getByRole('list', { name: 'Search results' })
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
test('should close the dropdown when the user clicks outside of the search bar', async ({
|
||||
page,
|
||||
isMobile
|
||||
}) => {
|
||||
const searchInput = await getSearchInput({ page, isMobile });
|
||||
await expect(searchInput).toBeVisible();
|
||||
|
||||
await searchInput.fill('test');
|
||||
|
||||
// Wait for the search results to show up
|
||||
const resultList = page.getByRole('list', { name: 'Search results' });
|
||||
await expect(resultList).toBeVisible();
|
||||
|
||||
await page.getByRole('navigation', { name: 'primary' }).click();
|
||||
await expect(resultList).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Search results when viewport height is greater than 768px', () => {
|
||||
test.use({
|
||||
viewport: { width: 1600, height: 1200 }
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/learn');
|
||||
|
||||
// Mock Algolia requests to prevent hitting Algolia server unnecessarily.
|
||||
// Comment out this line if you want to test against the real server.
|
||||
await mockAlgolia({ page, hitsPerPage: 8 });
|
||||
});
|
||||
|
||||
test('should display 8 items', async ({ page, isMobile }) => {
|
||||
test.skip(!haveApiKeys, 'This test requires Algolia API keys');
|
||||
|
||||
await search({ page, isMobile, query: 'article' });
|
||||
|
||||
// Wait for the search results to show up
|
||||
const results = page.getByRole('list', { name: 'Search results' });
|
||||
await expect(results.getByRole('listitem')).toHaveCount(9); // 8 results + the footer
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Search results when viewport height is equal to 768px', () => {
|
||||
test.use({
|
||||
viewport: { width: 1600, height: 768 }
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/learn');
|
||||
|
||||
// Mock Algolia requests to prevent hitting Algolia server unnecessarily.
|
||||
// Comment out this line if you want to test against the real server.
|
||||
await mockAlgolia({ page, hitsPerPage: 8 });
|
||||
});
|
||||
|
||||
test('should display 8 items', async ({ page, isMobile }) => {
|
||||
test.skip(!haveApiKeys, 'This test requires Algolia API keys');
|
||||
|
||||
await search({ page, isMobile, query: 'article' });
|
||||
|
||||
// Wait for the search results to show up
|
||||
const results = page.getByRole('list', { name: 'Search results' });
|
||||
await expect(results.getByRole('listitem')).toHaveCount(9); // 8 results + the footer
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Search results when viewport height is less than 768px', () => {
|
||||
test.use({
|
||||
viewport: { width: 1600, height: 500 }
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/learn');
|
||||
|
||||
// Mock Algolia requests to prevent hitting Algolia server unnecessarily.
|
||||
// Comment out this line if you want to test against the real server.
|
||||
await mockAlgolia({ page, hitsPerPage: 5 });
|
||||
});
|
||||
|
||||
test('should display 5 items', async ({ page, isMobile }) => {
|
||||
test.skip(!haveApiKeys, 'This test requires Algolia API keys');
|
||||
|
||||
await search({ page, isMobile, query: 'article' });
|
||||
|
||||
// Wait for the search results to show up
|
||||
const results = page.getByRole('list', { name: 'Search results' });
|
||||
await expect(results.getByRole('listitem')).toHaveCount(6); // 5 results + the footer
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.use({ storageState: 'playwright/.auth/certified-user.json' });
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Set viewport to desktop size to ensure sideNav is visible
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
await page.goto('/settings');
|
||||
|
||||
// Wait for the main heading to appear
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: 'Settings for certifieduser' })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test.describe('Settings SideNav Component', () => {
|
||||
test('should display the settings sideNav with links to all main sections', async ({
|
||||
page,
|
||||
isMobile
|
||||
}) => {
|
||||
test.setTimeout(30000);
|
||||
test.skip(isMobile, 'Sidebar is hidden on mobile');
|
||||
|
||||
const sideNav = page.getByRole('complementary');
|
||||
const main = page.getByRole('main');
|
||||
|
||||
// Get all h2 and h3 heading in the main section
|
||||
const h2Texts = await main
|
||||
.getByRole('heading', { level: 2 })
|
||||
.allTextContents();
|
||||
const h3Texts = await main
|
||||
.getByRole('heading', { level: 3 })
|
||||
.allTextContents();
|
||||
|
||||
const headingTexts = [...h2Texts, ...h3Texts];
|
||||
|
||||
// Make sure the sideNav contains the same number of links as headings
|
||||
const sideNavLinks = sideNav.getByRole('link');
|
||||
await expect(sideNavLinks).toHaveCount(headingTexts.length);
|
||||
|
||||
// For each heading text, find the link by accessible name and test click behavior
|
||||
for (const headingText of headingTexts) {
|
||||
const link = sideNav.getByRole('link', {
|
||||
name: headingText,
|
||||
exact: true
|
||||
});
|
||||
|
||||
await expect(link).toBeVisible();
|
||||
|
||||
// Get the href and assert the URL ends with it after click
|
||||
const href = await link.getAttribute('href');
|
||||
await link.click();
|
||||
|
||||
// Wait for scroll animation
|
||||
// Playwright performs click very fast, which could lead to URL check before scroll ends
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(href + '$'));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,356 @@
|
||||
import { execSync } from 'child_process';
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
import translations from '../client/i18n/locales/english/translations.json';
|
||||
import {
|
||||
currentCertifications,
|
||||
legacyCertifications as legacyCerts
|
||||
} from '@freecodecamp/shared/config/certification-settings';
|
||||
import { alertToBeVisible } from './utils/alerts';
|
||||
|
||||
const settingsTestIds = {
|
||||
settingsHeading: 'settings-heading',
|
||||
portfolioItems: 'portfolio-items'
|
||||
};
|
||||
|
||||
const settingsObject = {
|
||||
email: 'foo@bar.com',
|
||||
userNamePlaceholder: '{{username}}',
|
||||
certifiedUsername: 'certifieduser',
|
||||
testEmail: 'test@gmail.com',
|
||||
pageTitle: `${translations.buttons.settings} | freeCodeCamp.org`,
|
||||
private: 'Private',
|
||||
public: 'Public',
|
||||
supportEmail: 'support@freecodecamp.org',
|
||||
supportEmailPlaceholder: '<0>{{email}}</0>'
|
||||
};
|
||||
|
||||
const certifications = [
|
||||
translations.certification.title['foundational-c-sharp-with-microsoft']
|
||||
];
|
||||
|
||||
const legacyCertifications = [
|
||||
translations.certification.title['responsive-web-design'],
|
||||
translations.certification.title[
|
||||
'javascript-algorithms-and-data-structures-v8'
|
||||
],
|
||||
translations.certification.title['front-end-development-libraries'],
|
||||
translations.certification.title['data-visualization'],
|
||||
translations.certification.title['relational-database-v8'],
|
||||
translations.certification.title['back-end-development-and-apis'],
|
||||
translations.certification.title['quality-assurance-v7'],
|
||||
translations.certification.title['scientific-computing-with-python-v7'],
|
||||
translations.certification.title['data-analysis-with-python-v7'],
|
||||
translations.certification.title['information-security-v7'],
|
||||
translations.certification.title['machine-learning-with-python-v7'],
|
||||
translations.certification.title['college-algebra-with-python-v8'],
|
||||
translations.certification.title['legacy-front-end'],
|
||||
translations.certification.title['legacy-back-end'],
|
||||
translations.certification.title['legacy-data-visualization'],
|
||||
translations.certification.title['information-security-and-quality-assurance']
|
||||
];
|
||||
|
||||
test.describe('Settings - Certified User', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
execSync('node ../tools/scripts/seed/seed-demo-user --certified-user');
|
||||
await page.goto('/settings');
|
||||
});
|
||||
|
||||
test('Should render correctly', async ({ page }) => {
|
||||
// Title
|
||||
await expect(page).toHaveTitle(settingsObject.pageTitle);
|
||||
|
||||
// Header
|
||||
const header = page.getByTestId(settingsTestIds.settingsHeading);
|
||||
await expect(header).toBeVisible();
|
||||
await expect(header).toContainText(
|
||||
`${translations.settings.for.replace(
|
||||
settingsObject.userNamePlaceholder,
|
||||
settingsObject.certifiedUsername
|
||||
)}`
|
||||
);
|
||||
|
||||
// Privacy Settings
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: translations.settings.headings.privacy
|
||||
})
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(translations.settings.privacy)).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.getByRole('group', {
|
||||
name: translations.settings.labels['my-profile']
|
||||
})
|
||||
.locator('p')
|
||||
.filter({ hasText: translations.settings.labels['my-profile'] })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.getByRole('group', {
|
||||
name: translations.settings.labels['my-name']
|
||||
})
|
||||
.locator('p')
|
||||
.filter({ hasText: translations.settings.labels['my-name'] })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.getByRole('group', {
|
||||
name: translations.settings.labels['my-about']
|
||||
})
|
||||
.locator('p')
|
||||
.filter({ hasText: translations.settings.labels['my-about'] })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.getByRole('group', {
|
||||
name: translations.settings.labels['my-points']
|
||||
})
|
||||
.locator('p')
|
||||
.filter({ hasText: translations.settings.labels['my-points'] })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.getByRole('group', {
|
||||
name: translations.settings.labels['my-certs']
|
||||
})
|
||||
.locator('p')
|
||||
.filter({ hasText: translations.settings.labels['my-certs'] })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.getByRole('group', {
|
||||
name: translations.settings.labels['my-donations']
|
||||
})
|
||||
.locator('p')
|
||||
.filter({ hasText: translations.settings.labels['my-donations'] })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.getByRole('group', {
|
||||
name: translations.settings.labels['my-heatmap']
|
||||
})
|
||||
.locator('p')
|
||||
.filter({ hasText: translations.settings.labels['my-heatmap'] })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.getByRole('group', {
|
||||
name: translations.settings.labels['my-location']
|
||||
})
|
||||
.locator('p')
|
||||
.filter({ hasText: translations.settings.labels['my-location'] })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.getByRole('group', {
|
||||
name: translations.settings.labels['my-timeline']
|
||||
})
|
||||
.locator('p')
|
||||
.filter({ hasText: translations.settings.labels['my-timeline'] })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.getByRole('group', {
|
||||
name: translations.settings.labels['my-portfolio']
|
||||
})
|
||||
.locator('p')
|
||||
.filter({ hasText: translations.settings.labels['my-portfolio'] })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.getByRole('group', {
|
||||
name: translations.settings.labels['my-experience']
|
||||
})
|
||||
.locator('p')
|
||||
.filter({ hasText: translations.settings.labels['my-experience'] })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(settingsObject.private, { exact: true })
|
||||
).toHaveCount(11);
|
||||
await expect(
|
||||
page.getByText(settingsObject.public, { exact: true })
|
||||
).toHaveCount(11);
|
||||
const saveButton = page.getByRole('button', {
|
||||
name: translations.settings.headings.privacy
|
||||
});
|
||||
await expect(saveButton).toBeVisible();
|
||||
await expect(page.getByText(translations.settings.data)).toBeVisible();
|
||||
const downloadButton = page.getByRole('link', {
|
||||
name: translations.buttons['download-data']
|
||||
});
|
||||
await expect(downloadButton).toBeVisible();
|
||||
await expect(page.locator('#legendsound')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(translations.settings['sound-volume'])
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.getByRole('group', {
|
||||
name: translations.settings.labels['keyboard-shortcuts']
|
||||
})
|
||||
.locator('p')
|
||||
.first()
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(translations.settings['scrollbar-width'])
|
||||
).toBeVisible();
|
||||
|
||||
// Certifications
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: translations.settings.headings.certs,
|
||||
exact: true
|
||||
})
|
||||
).toBeVisible();
|
||||
for (let i = 0; i < certifications.length; i++) {
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: certifications[i],
|
||||
exact: true
|
||||
})
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('link', {
|
||||
name: `${translations.buttons['show-cert']} ${certifications[i]}`
|
||||
})
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
// Legacy Certifications
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: translations.settings.headings['legacy-certs'],
|
||||
exact: true
|
||||
})
|
||||
).toBeVisible();
|
||||
for (let i = 0; i < legacyCertifications.length; i++) {
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: legacyCertifications[i],
|
||||
exact: true
|
||||
})
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('link', {
|
||||
name: `${translations.buttons['show-cert']} ${legacyCertifications[i]}`,
|
||||
exact: true
|
||||
})
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
// Danger Zone
|
||||
await expect(page.getByRole('main').getByText('Danger Zone')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(
|
||||
'Please be careful. Changes in this section are permanent.'
|
||||
)
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: 'Reset all of my progress'
|
||||
})
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: 'Delete my account'
|
||||
})
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// In order to claim the Full-Stack cert, the user needs to complete 6 certs.
|
||||
// Instead of simulating 6 cert claim flows,
|
||||
// we use the data of Certified User but remove the Full-Stack cert.
|
||||
test.describe('Settings - Certified User without Full-Stack Certification', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
execSync(
|
||||
'node ../tools/scripts/seed/seed-demo-user --certified-user --set-false isFullStackCert'
|
||||
);
|
||||
await page.goto('/settings');
|
||||
});
|
||||
|
||||
test.afterAll(() => {
|
||||
execSync('node ../tools/scripts/seed/seed-demo-user --certified-user');
|
||||
});
|
||||
|
||||
test('should allow claiming Full-Stack cert if the user has completed all requirements', async ({
|
||||
page
|
||||
}) => {
|
||||
const claimButton = page.getByRole('button', {
|
||||
name: 'Claim Certification Legacy Full-Stack'
|
||||
});
|
||||
const showButton = page.getByRole('link', {
|
||||
name: 'Show Certification Legacy Full-Stack'
|
||||
});
|
||||
|
||||
await expect(claimButton).toBeVisible();
|
||||
await expect(claimButton).toBeEnabled();
|
||||
await claimButton.click();
|
||||
|
||||
await alertToBeVisible(
|
||||
page,
|
||||
'@certifieduser, you have successfully claimed the Legacy Full-Stack Certification! Congratulations on behalf of the freeCodeCamp.org team!'
|
||||
);
|
||||
await expect(claimButton).toBeHidden();
|
||||
await expect(showButton).toBeVisible();
|
||||
await expect(showButton).toHaveAttribute(
|
||||
'href',
|
||||
'/certification/certifieduser/full-stack'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Settings - New User', () => {
|
||||
test.use({ storageState: 'playwright/.auth/development-user.json' });
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
execSync('node ../tools/scripts/seed/seed-demo-user');
|
||||
await page.goto('/settings');
|
||||
});
|
||||
|
||||
test.afterAll(() => {
|
||||
execSync('node ../tools/scripts/seed/seed-demo-user --certified-user');
|
||||
});
|
||||
|
||||
test('should not allow claiming Full-Stack cert if the user has not completed all the required certs', async ({
|
||||
page
|
||||
}) => {
|
||||
const claimFullStackCertButton = page.getByRole('button', {
|
||||
name: 'Claim Certification Legacy Full-Stack'
|
||||
});
|
||||
|
||||
const claimRwdCertButton = page.getByRole('button', {
|
||||
name: 'Claim Certification Legacy Responsive Web Design V8'
|
||||
});
|
||||
|
||||
// Buttons for normal certs are enabled
|
||||
await expect(claimRwdCertButton).toBeVisible();
|
||||
await expect(claimRwdCertButton).toBeEnabled();
|
||||
|
||||
// Button for full-stack cert is disabled if the user hasn't claimed the required certs
|
||||
await expect(claimFullStackCertButton).toBeVisible();
|
||||
await expect(claimFullStackCertButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Setting - Hash Navigation', () => {
|
||||
test('should scroll to certification sections when navigating with hash', async ({
|
||||
page
|
||||
}) => {
|
||||
const allCerts = [...currentCertifications, ...legacyCerts];
|
||||
for (const certSlug of allCerts) {
|
||||
await page.goto(`/settings#cert-${certSlug}`);
|
||||
|
||||
// Wait for scroll animation
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const certHeading = page.getByRole('heading', {
|
||||
name: translations.certification.title[certSlug],
|
||||
exact: true
|
||||
});
|
||||
|
||||
await expect(certHeading).toBeInViewport();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { APIRequestContext, Page, expect, test } from '@playwright/test';
|
||||
|
||||
import translations from '../client/i18n/locales/english/translations.json';
|
||||
import { authedRequest } from './utils/request';
|
||||
import { getEditors } from './utils/editor';
|
||||
|
||||
const course =
|
||||
'/learn/javascript-algorithms-and-data-structures/basic-javascript/comment-your-javascript-code';
|
||||
|
||||
const enableKeyboardShortcuts = async (request: APIRequestContext) => {
|
||||
const res = await authedRequest({
|
||||
request,
|
||||
endpoint: '/update-my-keyboard-shortcuts',
|
||||
method: 'put',
|
||||
data: {
|
||||
keyboardShortcuts: true
|
||||
}
|
||||
});
|
||||
expect(await res.json()).toEqual({
|
||||
message: 'flash.keyboard-shortcut-updated',
|
||||
type: 'success'
|
||||
});
|
||||
};
|
||||
|
||||
const openModal = async (page: Page) => {
|
||||
// The editor pane is focused by default, so we need to escape or it will
|
||||
// capture the keyboard shortcuts
|
||||
await getEditors(page).press('Escape');
|
||||
await expect(page.getByTestId('hotkeys')).toBeFocused();
|
||||
await page.keyboard.press('Shift+?');
|
||||
};
|
||||
|
||||
test.beforeEach(async ({ page, isMobile, request }) => {
|
||||
test.skip(
|
||||
isMobile,
|
||||
'Skipping on mobile as it does not have a physical keyboard'
|
||||
);
|
||||
|
||||
await enableKeyboardShortcuts(request);
|
||||
await page.goto(course);
|
||||
});
|
||||
|
||||
test('the modal can be opened with SHIFT + ? and closed with ESC', async ({
|
||||
page
|
||||
}) => {
|
||||
await openModal(page);
|
||||
const dialog = page.getByRole('dialog', {
|
||||
name: translations.shortcuts.title
|
||||
});
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
for (const shortcut of Object.values(translations.shortcuts)) {
|
||||
if (shortcut === translations.shortcuts.title) continue;
|
||||
await expect(page.getByText(shortcut)).toBeVisible();
|
||||
}
|
||||
|
||||
await expect(
|
||||
page.getByText(translations.settings.labels['keyboard-shortcuts'])
|
||||
).toHaveCount(2);
|
||||
await expect(
|
||||
page.getByRole('button', { name: translations.buttons.on })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: translations.buttons.off })
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: translations.buttons.close })
|
||||
).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await expect(dialog).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('has a button to disable or enable keyboard shortcuts', async ({
|
||||
page
|
||||
}) => {
|
||||
await openModal(page);
|
||||
const dialog = page.getByRole('dialog', {
|
||||
name: translations.shortcuts.title
|
||||
});
|
||||
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: translations.buttons.off }).click();
|
||||
await page.getByRole('button', { name: translations.buttons.close }).click();
|
||||
|
||||
await expect(dialog).not.toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText(new RegExp(translations.flash['keyboard-shortcut-updated']))
|
||||
).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Shift+?');
|
||||
|
||||
await expect(dialog).not.toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { execSync } from 'child_process';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.describe('When the user HAS NOT claimed their cert', () => {
|
||||
test.use({ storageState: 'playwright/.auth/development-user.json' });
|
||||
|
||||
test.beforeAll(() => {
|
||||
execSync('node ../tools/scripts/seed/seed-demo-user');
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/learn/front-end-development-libraries');
|
||||
});
|
||||
|
||||
test('should see a "Go to settings to claim your certification" pointing to "/settings#cert-front-end-development-libraries"', async ({
|
||||
page
|
||||
}) => {
|
||||
const link = page.getByRole('link', {
|
||||
name: 'Go to settings to claim your certification'
|
||||
});
|
||||
|
||||
await expect(link).toBeVisible();
|
||||
await expect(link).toHaveAttribute(
|
||||
'href',
|
||||
'/settings#cert-front-end-development-libraries'
|
||||
);
|
||||
});
|
||||
|
||||
test.afterAll(() => {
|
||||
execSync('node ../tools/scripts/seed/seed-demo-user --certified-user');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('When the user HAS claimed their cert', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/learn/front-end-development-libraries');
|
||||
});
|
||||
|
||||
test('should see a "Show Certification" link pointing to "/certification/certifieduser/front-end-development-libraries"', async ({
|
||||
page
|
||||
}) => {
|
||||
const link = page.getByRole('link', {
|
||||
name: 'Show Certification'
|
||||
});
|
||||
|
||||
await expect(link).toBeVisible();
|
||||
await expect(link).toHaveAttribute(
|
||||
'href',
|
||||
'/certification/certifieduser/front-end-development-libraries'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import translations from '../client/i18n/locales/english/translations.json';
|
||||
|
||||
// Tests for challenges rendered by `client/src/templates/Challenges/odin/show.tsx`
|
||||
test.describe('Odin challenges', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(
|
||||
'/learn/foundational-c-sharp-with-microsoft/write-your-first-code-using-c-sharp/write-your-first-c-sharp-code'
|
||||
);
|
||||
});
|
||||
|
||||
test.describe('When the user is signed out', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test('should render the content correctly', async ({ page }) => {
|
||||
await expect(page).toHaveTitle(
|
||||
'Write Your First Code Using C# - Write Your First C# Code | Learn | freeCodeCamp.org'
|
||||
);
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
level: 1,
|
||||
name: 'Write Your First C# Code'
|
||||
})
|
||||
).toBeVisible();
|
||||
|
||||
// Checkmark doesn't show up if the user has completed the challenge but is signed out
|
||||
await expect(
|
||||
page.getByRole('img', { name: translations.icons.passed })
|
||||
).toBeHidden();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: translations.buttons['check-answer'] })
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: translations.buttons['ask-for-help'] })
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('When the user is signed in', () => {
|
||||
test('should render the content correctly', async ({ page }) => {
|
||||
await expect(page).toHaveTitle(
|
||||
'Write Your First Code Using C# - Write Your First C# Code | Learn | freeCodeCamp.org'
|
||||
);
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
level: 1,
|
||||
name: 'Write Your First C# Code'
|
||||
})
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('img', { name: translations.icons.passed })
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: translations.buttons['check-answer'] })
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: translations.buttons['ask-for-help'] })
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import translations from '../client/i18n/locales/english/translations.json';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(
|
||||
'/learn/javascript-algorithms-and-data-structures/basic-javascript/declare-javascript-variables'
|
||||
);
|
||||
});
|
||||
|
||||
test.describe('Challenge Side Panel Component', () => {
|
||||
test('should render correctly', async ({ page, isMobile }) => {
|
||||
if (isMobile) {
|
||||
const toolPanelItem = page.getByText(translations.buttons['get-help']);
|
||||
await expect(toolPanelItem).toBeVisible();
|
||||
}
|
||||
await expect(page.getByTestId('challenge-title')).toBeVisible();
|
||||
await expect(page.getByTestId('challenge-description')).toBeVisible();
|
||||
await expect(page.getByTestId('test-result')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('signing in', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
test('welcomes the user', async ({ page }) => {
|
||||
const welcomeText = 'Welcome back, Full Stack User.';
|
||||
await page.goto('/learn');
|
||||
await expect(page.getByText(welcomeText)).not.toBeVisible();
|
||||
|
||||
await page.getByRole('link', { name: 'Sign in' }).first().click();
|
||||
|
||||
await expect(page.getByText(welcomeText)).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import translations from '../client/i18n/locales/english/translations.json';
|
||||
import { allowTrailingSlash } from './utils/url';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test.describe('Signout Modal component', () => {
|
||||
test('renders the modal correctly', async ({ page }) => {
|
||||
await page.getByRole('button', { name: translations.buttons.menu }).click();
|
||||
await page
|
||||
.getByRole('button', { name: translations.buttons['sign-out'] })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('dialog', { name: translations.signout.heading })
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page.getByText(translations.signout.p1)).toBeVisible();
|
||||
await expect(page.getByText(translations.signout.p2)).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: translations.signout.nevermind })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: translations.signout.certain })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: translations.buttons.close })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('signs out and redirects to / after user confirms they want to sign out', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.getByRole('button', { name: translations.buttons.menu }).click();
|
||||
await page
|
||||
.getByRole('button', { name: translations.buttons['sign-out'] })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('dialog', { name: translations.signout.heading })
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: translations.signout.certain })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('dialog', { name: translations.signout.heading })
|
||||
).not.toBeVisible();
|
||||
await expect(page).toHaveURL(allowTrailingSlash(''));
|
||||
});
|
||||
|
||||
test('closes modal after user cancels signing out', async ({ page }) => {
|
||||
await page.getByRole('button', { name: translations.buttons.menu }).click();
|
||||
await page
|
||||
.getByRole('button', { name: translations.buttons['sign-out'] })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('dialog', { name: translations.signout.heading })
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: translations.signout.nevermind })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('dialog', { name: translations.signout.heading })
|
||||
).not.toBeVisible();
|
||||
|
||||
await expect(page).toHaveURL('/');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Solution Viewer component', () => {
|
||||
test('renders the modal correctly', async ({ page }) => {
|
||||
await page.goto(
|
||||
'/certification/certifieduser/javascript-algorithms-and-data-structures'
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: /view/i }).first().click();
|
||||
|
||||
const projectSolutionViewerModal = page.getByRole('dialog', {
|
||||
name: 'Solution for'
|
||||
});
|
||||
// The modal should show the solution title...
|
||||
await expect(projectSolutionViewerModal).toBeVisible();
|
||||
// ...and relevant code file/s
|
||||
await expect(page.getByText(/js/i)).toBeVisible();
|
||||
await expect(page.locator('pre').first()).toBeVisible();
|
||||
|
||||
const closeButtons = await page
|
||||
.getByRole('button', { name: 'Close' })
|
||||
.all();
|
||||
|
||||
const topRightCloseButton = closeButtons[0];
|
||||
await topRightCloseButton.click();
|
||||
await expect(projectSolutionViewerModal).toBeHidden();
|
||||
|
||||
await page.getByRole('button', { name: 'View' }).first().click();
|
||||
const bottomRightCloseButton = closeButtons[1];
|
||||
await bottomRightCloseButton.click();
|
||||
await expect(projectSolutionViewerModal).toBeHidden();
|
||||
});
|
||||
|
||||
test('renders external project links correctly', async ({ page }) => {
|
||||
await page.goto(
|
||||
'/certification/certifieduser/front-end-development-libraries'
|
||||
);
|
||||
|
||||
const projectLink = page.getByRole('link', { name: 'View' }).first();
|
||||
const browserContext = page.context();
|
||||
|
||||
const [newPage] = await Promise.all([
|
||||
browserContext.waitForEvent('page'),
|
||||
projectLink.click()
|
||||
]);
|
||||
|
||||
await newPage.waitForLoadState();
|
||||
|
||||
await expect(newPage).toHaveURL(/^https:\/\/codepen\.io/);
|
||||
|
||||
await newPage.close();
|
||||
});
|
||||
|
||||
test('render projects with multiple solutions correctly', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/certification/certifieduser/quality-assurance-v7');
|
||||
|
||||
const dropdownButton = page.getByRole('button', { name: 'View' }).first();
|
||||
await dropdownButton.click();
|
||||
|
||||
await expect(page.getByRole('menu')).toBeVisible();
|
||||
|
||||
const sourceLink = page.getByRole('menuitem', { name: /source/i }).first();
|
||||
|
||||
const browserContext = page.context();
|
||||
const [newPage] = await Promise.all([
|
||||
browserContext.waitForEvent('page'),
|
||||
sourceLink.click()
|
||||
]);
|
||||
|
||||
await newPage.waitForLoadState();
|
||||
|
||||
await newPage.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import translations from '../client/i18n/locales/english/translations.json';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test.describe('Staging Warning Modal E2E Test Suite', () => {
|
||||
test('Verifies the Correct Rendering of the Staging Warning Modal', async ({
|
||||
page
|
||||
}) => {
|
||||
if (
|
||||
process.env.DEPLOYMENT_ENV === 'staging' &&
|
||||
process.env.FREECODECAMP_NODE_ENV === 'production'
|
||||
) {
|
||||
await expect(
|
||||
page.getByText(translations['staging-warning'].heading)
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(translations['staging-warning'].p1)
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(translations['staging-warning'].p2)
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: translations['staging-warning'].certain
|
||||
})
|
||||
).toBeVisible();
|
||||
|
||||
const link = page.getByRole('link', { name: 'following this link' });
|
||||
await expect(link).toHaveAttribute(
|
||||
'href',
|
||||
'https://contribute.freecodecamp.org/#/devops?id=known-limitations'
|
||||
);
|
||||
await expect(link).toHaveAttribute('target', '_blank');
|
||||
await expect(link).toHaveAttribute('rel', 'noopener noreferrer nofollow');
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: translations.buttons.close })
|
||||
).toBeVisible();
|
||||
} else {
|
||||
await expect(
|
||||
page.getByText(translations['staging-warning'].heading)
|
||||
).not.toBeVisible();
|
||||
await expect(
|
||||
page.getByText(translations['staging-warning'].p1)
|
||||
).not.toBeVisible();
|
||||
await expect(
|
||||
page.getByText(translations['staging-warning'].p2)
|
||||
).not.toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: translations['staging-warning'].certain
|
||||
})
|
||||
).not.toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: translations.buttons.close })
|
||||
).not.toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('Closes the Staging Warning Modal When the User clicks on cancel button', async ({
|
||||
page
|
||||
}) => {
|
||||
if (
|
||||
process.env.DEPLOYMENT_ENV === 'staging' &&
|
||||
process.env.FREECODECAMP_NODE_ENV === 'production'
|
||||
) {
|
||||
await page
|
||||
.getByRole('button', { name: translations.buttons.close })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByText(translations['staging-warning'].heading)
|
||||
).not.toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('Closes the Modal when the User Accepts the Staging Warning', async ({
|
||||
page
|
||||
}) => {
|
||||
if (
|
||||
process.env.DEPLOYMENT_ENV === 'staging' &&
|
||||
process.env.FREECODECAMP_NODE_ENV === 'production'
|
||||
) {
|
||||
await page
|
||||
.getByRole('button', { name: translations['staging-warning'].certain })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByText(translations['staging-warning'].heading)
|
||||
).not.toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user