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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions e2e/helpers/probe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { expect, Page } from '@playwright/test';
import { probe } from './selectors';

/**
* Probe panel call line (activities/inputs/shared/ProbePanel.vue).
*
* The student edits only the argument list between `name(` and `)`: one
* contenteditable #probe-args holding Python-style arguments, positional or
* keyword (`7`, `n=7`, `["a", "b"], prefix="x"`).
*/

/**
* Replace the argument list with `text` and wait until the editable holds it.
*
* Locator.fill selects the current contents and inserts the text as one input
* event, which a contenteditable accepts, brackets and quotes included.
*/
export async function fillProbeArgs(page: Page, text: string): Promise<void> {
const line = page.locator(probe.argsLine);
await line.fill(text);
await expect(line).toHaveJSProperty('textContent', text);
}

/** Text of the result slot after the arrow ('?' until a result). */
export async function readProbeResult(page: Page): Promise<string> {
return (await page.locator(probe.result).innerText()).trim();
}

/** Text of the remaining-probe badge, e.g. '· ∞' or '· 2 left'. */
export async function readBadge(page: Page): Promise<string> {
return (await page.locator(probe.badge).innerText()).trim();
}

/**
* Assert the error shown for one argument. `name` is a parameter name, or
* 'line' for a line-level error (unknown, duplicate, extra or empty argument).
*/
export async function expectArgError(page: Page, name: string, text: string | RegExp): Promise<void> {
await expect(page.locator(probe.fieldError(name))).toHaveText(text);
}

/**
* Assert whether the argument list can be edited. Locked means
* contenteditable="false" plus aria-disabled, as when probes are exhausted.
*/
export async function expectArgsEditable(page: Page, editable: boolean): Promise<void> {
const line = page.locator(probe.argsLine);
if (editable) {
// The e2e project is Chromium-only, which supports plaintext-only; the 'true'
// fallback for other browsers is covered in Vitest (probeArgsLine.test.ts)
await expect(line).toHaveAttribute('contenteditable', 'plaintext-only');
await expect(line).toBeEnabled();
} else {
await expect(line).toHaveAttribute('contenteditable', 'false');
await expect(line).toHaveAttribute('aria-disabled', 'true');
await expect(line).toBeDisabled();
}
}

/** The placeholder actually painted in the empty call line (CSS :empty::before). */
export async function readArgsPlaceholder(page: Page): Promise<string> {
return page.locator(probe.argsLine).evaluate((el) => {
const content = getComputedStyle(el, '::before').content;
// 'none' when the line is not empty; otherwise a quoted CSS string
return content === 'none' ? '' : JSON.parse(content);
});
}
14 changes: 12 additions & 2 deletions e2e/helpers/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,8 +247,16 @@ export const common = {
export const probe = {
/** Panel root */
panel: '.probe-panel',
/** Input for one parameter (Enter triggers a probe) */
paramInput: (name: string) => `input#probe-${name}.param-input`,
/** Row holding the call and the History button (wraps on narrow screens) */
row: '.probe-panel .probe-row',
/** Box around the call line: `name(` + arguments + `)` (.has-error / .is-disabled) */
callShell: '.probe-panel .call-shell',
/** Editable argument list between the parentheses (Enter probes, Tab fills names) */
argsLine: '#probe-args',
/** Closing parenthesis after the argument list */
closeParen: '.probe-panel .call-shell .fn-paren:last-of-type',
/** 'Fill in argument names' button: inserts `name=, name=` */
fillArgsButton: 'button.fill-args-btn',
/** Result slot ('?' until a result; .has-result / .is-cached) */
result: '.probe-panel .fn-result',
/** Probe button ('Probe' | 'Probing' | 'Already probed', .is-duplicate) */
Expand All @@ -263,6 +271,8 @@ export const probe = {
fieldErrors: 'ul.probe-field-errors',
/** One parameter's field error */
fieldError: (name: string) => `li#probe-err-${name}`,
/** Line-level error (unknown keyword, duplicate/empty argument, too many arguments) */
lineError: 'li#probe-err-line',
/** 'History (N)' button, rendered only when history is non-empty */
historyButton: '.probe-panel .history-btn',
/** History modal (teleported to body) */
Expand Down
40 changes: 19 additions & 21 deletions e2e/student/probeable-budget.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,15 @@
* keep their values, so the button switches to "Already probed"; the spec
* enters new values before it reads the badge.
* - A duplicate input is answered from the client-side history: no request.
* - The arguments are typed into one call line (#probe-args) as `a=1, b=2`;
* once probes are exhausted it is contenteditable="false" + aria-disabled.
*/

import { test, expect, Page, Response } from '@playwright/test';
import { navigateAsFreshStudent, freshStudent, injectUser, enrollUser } from '../helpers/users';
import { submitCodeAndWait } from '../helpers/submission';
import { probe } from '../helpers/selectors';
import { expectArgsEditable, fillProbeArgs } from '../helpers/probe';

const SET_URL = '/courses/CS101-2024/problem-set/e2e-probeable';
const BLOCK_URL = `${SET_URL}?p=0`;
Expand All @@ -37,16 +40,16 @@ function isProbePost(resp: Response, slug: string): boolean {
);
}

/** Wait until the probe panel and its add(a, b) inputs are on screen. */
/** Wait until the probe panel and its add(a, b) call line are on screen. */
async function waitForProbePanel(page: Page): Promise<void> {
await expect(page.locator(probe.panel)).toBeVisible({ timeout: 15_000 });
await expect(page.locator(probe.paramInput('a'))).toBeVisible();
await expect(page.locator(probe.paramInput('b'))).toBeVisible();
await expect(page.locator('.probe-panel .fn-name')).toHaveText('add');
await expect(page.locator(probe.argsLine)).toBeVisible();
}

/** Type add(a=<a>, b=<b>) into the call line. */
async function fillArgs(page: Page, a: string, b: string): Promise<void> {
await page.locator(probe.paramInput('a')).fill(a);
await page.locator(probe.paramInput('b')).fill(b);
await fillProbeArgs(page, `a=${a}, b=${b}`);
}

/**
Expand Down Expand Up @@ -128,7 +131,7 @@ test.describe('Probe budget: block mode', () => {
await expect(page.locator(probe.result)).toHaveClass(/is-cached/);

// Confirming the duplicate from the keyboard must not probe either.
await page.locator(probe.paramInput('b')).press('Enter');
await page.locator(probe.argsLine).press('Enter');

// The duplicate was free: one probe is still left.
await fillArgs(page, '3', '3');
Expand All @@ -144,8 +147,7 @@ test.describe('Probe budget: block mode', () => {
// Exhausted state without a reload.
await expect(page.locator(probe.hint)).toHaveText('No probes remaining.');
await expect(page.locator(probe.button)).toBeDisabled();
await expect(page.locator(probe.paramInput('a'))).toBeDisabled();
await expect(page.locator(probe.paramInput('b'))).toBeDisabled();
await expectArgsEditable(page, false);
await expect(page.locator(probe.historyButton)).toHaveText('History (3)');

// Server-side state survives a fresh load (second tab, same student).
Expand All @@ -157,8 +159,7 @@ test.describe('Probe budget: block mode', () => {
await expectBadge(freshTab, 0, 'status-exhausted');
await expect(freshTab.locator(probe.hint)).toHaveText('No probes remaining.');
await expect(freshTab.locator(probe.button)).toBeDisabled();
await expect(freshTab.locator(probe.paramInput('a'))).toBeDisabled();
await expect(freshTab.locator(probe.paramInput('b'))).toBeDisabled();
await expectArgsEditable(freshTab, false);
await expect(freshTab.locator(probe.result)).toHaveText('6');

// Earlier results stay readable from the history after exhaustion.
Expand Down Expand Up @@ -206,9 +207,9 @@ test.describe('Probe budget: block mode', () => {
await expect(page.locator(probe.result)).toHaveClass(/is-cached/);
await expect(page.locator(probe.result)).toHaveText('3');

// Enter in a parameter field is the keyboard path to "Probe". On a
// Enter in the call line is the keyboard path to "Probe". On a
// duplicate it must do nothing, like the disabled button.
await page.locator(probe.paramInput('b')).press('Enter');
await page.locator(probe.argsLine).press('Enter');

// Budget is server-side: a fresh load shows whether anything was spent.
await page.reload({ waitUntil: 'networkidle' });
Expand Down Expand Up @@ -283,8 +284,7 @@ test.describe('Probe budget: cooldown mode', () => {

await expect(page.locator(probe.hint)).toHaveText(refillHint);
await expect(page.locator(probe.button)).toBeDisabled();
await expect(page.locator(probe.paramInput('a'))).toBeDisabled();
await expect(page.locator(probe.paramInput('b'))).toBeDisabled();
await expectArgsEditable(page, false);

// One submission earns the refill.
await submitCodeAndWait(page, ADD_SOLUTION);
Expand All @@ -298,17 +298,15 @@ test.describe('Probe budget: cooldown mode', () => {
await waitForProbePanel(freshTab);
await expectBadge(freshTab, 2, 'status-available');
await expect(freshTab.locator(probe.hint)).toHaveCount(0);
await expect(freshTab.locator(probe.paramInput('a'))).toBeEnabled();
await expect(freshTab.locator(probe.paramInput('b'))).toBeEnabled();
await expectArgsEditable(freshTab, true);
} finally {
await freshTab.close();
}

// Without a reload, the page the student submitted from must show the
// refilled budget too.
await expect(page.locator(probe.hint)).toHaveCount(0);
await expect(page.locator(probe.paramInput('a'))).toBeEnabled();
await expect(page.locator(probe.paramInput('b'))).toBeEnabled();
await expectArgsEditable(page, true);
await fillArgs(page, '3', '3');
await expectBadge(page, 2, 'status-available');

Expand Down Expand Up @@ -337,21 +335,21 @@ test.describe('Probe budget: cooldown mode', () => {
await expect(page.locator(probe.hint)).toHaveText(
'No probes remaining. Submit 2 more times to unlock more probes.',
);
await expect(page.locator(probe.paramInput('a'))).toBeDisabled();
await expectArgsEditable(page, false);

// First submission: one more is still needed, and the budget stays empty.
await submitCodeAndWait(page, ADD_SOLUTION);
await expect(page.locator(probe.hint)).toHaveText(
'No probes remaining. Submit 1 more time to unlock more probes.',
);
await expect(page.locator(probe.paramInput('a'))).toBeDisabled();
await expectArgsEditable(page, false);
await expect(page.locator(probe.button)).toBeDisabled();
await expectBadge(page, 0, 'status-exhausted');

// Second submission earns the refill.
await submitCodeAndWait(page, ADD_SOLUTION);
await expect(page.locator(probe.hint)).toHaveCount(0);
await expect(page.locator(probe.paramInput('a'))).toBeEnabled();
await expectArgsEditable(page, true);
await fillArgs(page, '2', '2');
await expectBadge(page, 1, 'status-available');

Expand Down
Loading
Loading