diff --git a/backend/src/app.ts b/backend/src/app.ts index bf676c1..670e485 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -22,6 +22,7 @@ import { import { config } from "./config.js"; import { ensureDatabaseSchema, prisma } from "./db.js"; import { generateExpectedScreenshot, judgeSubmission } from "./judge.js"; +import { queuedJobCount, queueSequential } from "./sequentialQueue.js"; import type { CheckDefinition } from "./types.js"; const upload = multer({ @@ -37,6 +38,10 @@ const checkSchema = z.object({ "attribute-equals", "css-property", "element-count", + "interaction-text-contains", + "interaction-attribute-equals", + "interaction-css-property", + "interaction-element-count", "visual-match" ], { errorMap: () => ({ message: "Choose a supported check type." }) } @@ -45,6 +50,9 @@ const checkSchema = z.object({ value: z.string().optional(), attribute: z.string().optional(), property: z.string().optional(), + actionSelector: z.string().optional(), + inputSelector: z.string().optional(), + inputValue: z.string().optional(), count: z.coerce.number({ invalid_type_error: "Enter a valid element count." }).int().nonnegative().optional(), threshold: z.coerce.number({ invalid_type_error: "Enter a valid visual threshold." }).min(0).max(1).optional(), weight: z.coerce.number({ invalid_type_error: "Enter a valid check weight." }).positive("Check weight must be greater than 0.").default(1), @@ -55,7 +63,8 @@ function validateChecks(checks: z.infer[], context: z.Refine checks.forEach((check, index) => { const label = `Check ${index + 1}`; const needsSelector = check.type !== "visual-match"; - const needsValue = ["text-contains", "attribute-equals", "css-property"].includes(check.type); + const needsValue = ["text-contains", "attribute-equals", "css-property", "interaction-text-contains", "interaction-attribute-equals", "interaction-css-property"].includes(check.type); + const isInteraction = check.type.startsWith("interaction-"); if (needsSelector && !check.selector?.trim()) { context.addIssue({ @@ -73,7 +82,15 @@ function validateChecks(checks: z.infer[], context: z.Refine }); } - if (check.type === "attribute-equals" && !check.attribute?.trim()) { + if (isInteraction && !check.actionSelector?.trim()) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["checks", index, "actionSelector"], + message: `${label}: enter the button/action selector to click.` + }); + } + + if (["attribute-equals", "interaction-attribute-equals"].includes(check.type) && !check.attribute?.trim()) { context.addIssue({ code: z.ZodIssueCode.custom, path: ["checks", index, "attribute"], @@ -81,7 +98,7 @@ function validateChecks(checks: z.infer[], context: z.Refine }); } - if (check.type === "css-property" && !check.property?.trim()) { + if (["css-property", "interaction-css-property"].includes(check.type) && !check.property?.trim()) { context.addIssue({ code: z.ZodIssueCode.custom, path: ["checks", index, "property"], @@ -89,7 +106,7 @@ function validateChecks(checks: z.infer[], context: z.Refine }); } - if (check.type === "element-count" && check.count === undefined) { + if (["element-count", "interaction-element-count"].includes(check.type) && check.count === undefined) { context.addIssue({ code: z.ZodIssueCode.custom, path: ["checks", index, "count"], @@ -367,11 +384,15 @@ async function maybeGenerateExpected(task: { return null; } - return generateExpectedScreenshot(task.id, { + return queueSequential(() => generateExpectedScreenshot(task.id, { html: task.referenceHtml, css: task.referenceCss, js: task.referenceJs - }); + })); +} + +function queueJudgeSubmission(options: Parameters[0]) { + return queueSequential(() => judgeSubmission(options)); } async function seedSampleData() { @@ -451,7 +472,7 @@ export async function createApp() { app.use("/generated", express.static(config.generatedDir)); app.get("/api/health", (_req, res) => { - res.json({ ok: true }); + res.json({ ok: true, queuedJobs: queuedJobCount() }); }); app.post("/api/auth/admin", (req, res) => { @@ -569,7 +590,7 @@ export async function createApp() { } const studentName = normalizeStudentName(parsed.data.studentName); - const result = await judgeSubmission({ + const result = await queueJudgeSubmission({ taskId: task.id, checksJson: task.checksJson, expectedScreenshotPath: task.expectedScreenshotPath, @@ -845,7 +866,7 @@ export async function createApp() { return; } - const result = await judgeSubmission({ + const result = await queueJudgeSubmission({ taskId: submission.taskId, checksJson: submission.task.checksJson, expectedScreenshotPath: submission.task.expectedScreenshotPath, @@ -902,10 +923,16 @@ export async function createApp() { res.json({ url, markdown: `![Downloaded image](${url})` }); }); - if (config.nodeEnv === "production") { + const frontendIndexPath = path.join(config.frontendDistDir, "index.html"); + const hasBuiltFrontend = await fs + .access(frontendIndexPath) + .then(() => true) + .catch(() => false); + + if (config.nodeEnv === "production" || hasBuiltFrontend) { app.use(express.static(config.frontendDistDir)); app.get("*", (_req, res) => { - res.sendFile(path.join(config.frontendDistDir, "index.html")); + res.sendFile(frontendIndexPath); }); } diff --git a/backend/src/judge.ts b/backend/src/judge.ts index 8b6334e..cdbcbad 100644 --- a/backend/src/judge.ts +++ b/backend/src/judge.ts @@ -55,6 +55,20 @@ function checkWeight(check: CheckDefinition) { return typeof check.weight === "number" && Number.isFinite(check.weight) && check.weight > 0 ? check.weight : 1; } +function isInteractionCheck(check: CheckDefinition) { + return check.type.startsWith("interaction-"); +} + +async function runInteraction(page: Awaited>["page"], check: CheckDefinition) { + if (check.inputSelector) { + await page.locator(check.inputSelector).first().fill(check.inputValue ?? ""); + } + if (check.actionSelector) { + await page.locator(check.actionSelector).first().click(); + await page.waitForTimeout(50); + } +} + export function summarizeWeightedResults(results: CheckResult[], passingScore = 0.8) { const totalWeight = results.reduce((sum, result) => sum + (result.weight ?? 1), 0); const passedWeight = results.reduce((sum, result) => sum + (result.passed ? result.weight ?? 1 : 0), 0); @@ -222,6 +236,97 @@ export async function judgeSubmission(options: { continue; } + if (isInteractionCheck(check)) { + if (!check.selector || !check.actionSelector) { + results.push({ + type: check.type, + message: check.message, + passed: false, + weight: checkWeight(check), + details: "This interaction check is missing a result selector or action selector." + }); + continue; + } + + const interactionPage = await page.context().newPage(); + interactionPage.setDefaultTimeout(1500); + interactionPage.on("dialog", (dialog) => dialog.dismiss().catch(() => undefined)); + + try { + await interactionPage.setContent(buildHtmlDocument(options.bundle), { + waitUntil: "domcontentloaded", + timeout: 2000 + }); + await interactionPage.waitForTimeout(50); + await runInteraction(interactionPage, check); + } catch (error) { + await interactionPage.close().catch(() => undefined); + results.push({ + type: check.type, + message: check.message, + passed: false, + weight: checkWeight(check), + details: error instanceof Error ? error.message : "Could not perform the interaction." + }); + continue; + } + + const locator = interactionPage.locator(check.selector); + const count = await locator.count(); + + if (check.type === "interaction-text-contains") { + const text = count > 0 ? await locator.first().textContent() : ""; + const expected = check.value ?? ""; + results.push({ + type: check.type, + message: check.message, + passed: Boolean(text?.toLocaleLowerCase("en-US").includes(expected.toLocaleLowerCase("en-US"))), + weight: checkWeight(check), + details: `After clicking ${check.actionSelector}, expected text containing "${expected}".` + }); + } + + if (check.type === "interaction-element-count") { + const expectedCount = check.count ?? Number(check.value ?? 0); + results.push({ + type: check.type, + message: check.message, + passed: count === expectedCount, + weight: checkWeight(check), + details: `After clicking ${check.actionSelector}, found ${count}, expected ${expectedCount}.` + }); + } + + if (check.type === "interaction-attribute-equals") { + const actual = count > 0 ? await locator.first().getAttribute(check.attribute ?? "") : null; + const expected = check.value ?? ""; + results.push({ + type: check.type, + message: check.message, + passed: actual === expected, + weight: checkWeight(check), + details: `After clicking ${check.actionSelector}, expected ${check.attribute}="${expected}", found ${actual ?? "nothing"}.` + }); + } + + if (check.type === "interaction-css-property") { + const actual = count > 0 + ? await locator.first().evaluate((el, property) => getComputedStyle(el).getPropertyValue(property), check.property ?? "") + : ""; + const expected = check.value ?? ""; + results.push({ + type: check.type, + message: check.message, + passed: actual.trim() === expected.trim(), + weight: checkWeight(check), + details: `After clicking ${check.actionSelector}, expected ${check.property}: ${expected}, found ${actual || "nothing"}.` + }); + } + + await interactionPage.close().catch(() => undefined); + continue; + } + if (!check.selector) { results.push({ type: check.type, diff --git a/backend/src/sequentialQueue.ts b/backend/src/sequentialQueue.ts new file mode 100644 index 0000000..e6beaee --- /dev/null +++ b/backend/src/sequentialQueue.ts @@ -0,0 +1,27 @@ +type Job = () => Promise; + +let tail = Promise.resolve(); +let pendingJobs = 0; + +export function queueSequential(job: Job): Promise { + pendingJobs += 1; + + const run = tail.then(async () => { + try { + return await job(); + } finally { + pendingJobs -= 1; + } + }); + + tail = run.then( + () => undefined, + () => undefined + ); + + return run; +} + +export function queuedJobCount() { + return pendingJobs; +} diff --git a/backend/src/types.ts b/backend/src/types.ts index 226ec5d..eb324d2 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -4,6 +4,10 @@ export type CheckType = | "attribute-equals" | "css-property" | "element-count" + | "interaction-text-contains" + | "interaction-attribute-equals" + | "interaction-css-property" + | "interaction-element-count" | "visual-match"; export type CheckDefinition = { @@ -13,6 +17,9 @@ export type CheckDefinition = { value?: string; attribute?: string; property?: string; + actionSelector?: string; + inputSelector?: string; + inputValue?: string; count?: number; threshold?: number; weight?: number; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 084ca8e..4fe0f7e 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -34,6 +34,10 @@ type CheckType = | "attribute-equals" | "css-property" | "element-count" + | "interaction-text-contains" + | "interaction-attribute-equals" + | "interaction-css-property" + | "interaction-element-count" | "visual-match"; type CheckDefinition = { @@ -42,6 +46,9 @@ type CheckDefinition = { value?: string; attribute?: string; property?: string; + actionSelector?: string; + inputSelector?: string; + inputValue?: string; count?: number; threshold?: number; weight?: number; @@ -1149,6 +1156,24 @@ function CheckBuilder(props: { onChange: (index: number, patch: Partial) => void; onDelete: (index: number) => void; }) { + const valueCheckTypes: CheckType[] = [ + "text-contains", + "attribute-equals", + "css-property", + "interaction-text-contains", + "interaction-attribute-equals", + "interaction-css-property" + ]; + const attributeCheckTypes: CheckType[] = ["attribute-equals", "interaction-attribute-equals"]; + const cssCheckTypes: CheckType[] = ["css-property", "interaction-css-property"]; + const countCheckTypes: CheckType[] = ["element-count", "interaction-element-count"]; + const interactionCheckTypes: CheckType[] = [ + "interaction-text-contains", + "interaction-attribute-equals", + "interaction-css-property", + "interaction-element-count" + ]; + return (
@@ -1165,21 +1190,32 @@ function CheckBuilder(props: { + + + + {check.type !== "visual-match" && ( props.onChange(index, { selector: event.target.value })} placeholder="selector" /> )} - {check.type === "attribute-equals" && ( + {interactionCheckTypes.includes(check.type) && ( + <> + props.onChange(index, { actionSelector: event.target.value })} placeholder="click selector" /> + props.onChange(index, { inputSelector: event.target.value })} placeholder="input selector" /> + props.onChange(index, { inputValue: event.target.value })} placeholder="input value" /> + + )} + {attributeCheckTypes.includes(check.type) && ( props.onChange(index, { attribute: event.target.value })} placeholder="attribute" /> )} - {check.type === "css-property" && ( + {cssCheckTypes.includes(check.type) && ( props.onChange(index, { property: event.target.value })} placeholder="property" /> )} - {["text-contains", "attribute-equals", "css-property"].includes(check.type) && ( + {valueCheckTypes.includes(check.type) && ( props.onChange(index, { value: event.target.value })} placeholder="expected value" /> )} - {check.type === "element-count" && ( + {countCheckTypes.includes(check.type) && ( props.onChange(index, { count: Number(event.target.value) })} /> )} {check.type === "visual-match" && ( diff --git a/frontend/src/styles.css b/frontend/src/styles.css index d3dc253..eb5f0c7 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -314,6 +314,7 @@ p { grid-template-columns: minmax(360px, 1fr) minmax(320px, 42%); gap: 1rem; margin-top: 1rem; + min-width: 0; } .editors, @@ -327,6 +328,7 @@ p { .submission-detail { display: grid; gap: 0.7rem; + min-width: 0; } .editor-section, @@ -340,6 +342,7 @@ p { background: #fff; border: 1px solid #d8e0e8; border-radius: 8px; + min-width: 0; } .section-title, @@ -569,6 +572,7 @@ p { .submitted-code { display: grid; gap: 0.7rem; + min-width: 0; } .submitted-code h3 { @@ -657,11 +661,28 @@ p { } .cm-editor { + width: 100%; + max-width: 100%; + min-width: 0; border: 1px solid #d8e0e8; border-radius: 6px; overflow: hidden; } +.cm-scroller { + max-width: 100%; + overflow-x: auto; +} + +.cm-content { + overflow-wrap: anywhere; +} + +.cm-line { + white-space: pre-wrap; + overflow-wrap: anywhere; +} + @media (max-width: 1050px) { .student-layout, .admin-layout,