Fixes and improvements
This commit is contained in:
+38
-11
@@ -22,6 +22,7 @@ import {
|
|||||||
import { config } from "./config.js";
|
import { config } from "./config.js";
|
||||||
import { ensureDatabaseSchema, prisma } from "./db.js";
|
import { ensureDatabaseSchema, prisma } from "./db.js";
|
||||||
import { generateExpectedScreenshot, judgeSubmission } from "./judge.js";
|
import { generateExpectedScreenshot, judgeSubmission } from "./judge.js";
|
||||||
|
import { queuedJobCount, queueSequential } from "./sequentialQueue.js";
|
||||||
import type { CheckDefinition } from "./types.js";
|
import type { CheckDefinition } from "./types.js";
|
||||||
|
|
||||||
const upload = multer({
|
const upload = multer({
|
||||||
@@ -37,6 +38,10 @@ const checkSchema = z.object({
|
|||||||
"attribute-equals",
|
"attribute-equals",
|
||||||
"css-property",
|
"css-property",
|
||||||
"element-count",
|
"element-count",
|
||||||
|
"interaction-text-contains",
|
||||||
|
"interaction-attribute-equals",
|
||||||
|
"interaction-css-property",
|
||||||
|
"interaction-element-count",
|
||||||
"visual-match"
|
"visual-match"
|
||||||
],
|
],
|
||||||
{ errorMap: () => ({ message: "Choose a supported check type." }) }
|
{ errorMap: () => ({ message: "Choose a supported check type." }) }
|
||||||
@@ -45,6 +50,9 @@ const checkSchema = z.object({
|
|||||||
value: z.string().optional(),
|
value: z.string().optional(),
|
||||||
attribute: z.string().optional(),
|
attribute: z.string().optional(),
|
||||||
property: 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(),
|
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(),
|
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),
|
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<typeof checkSchema>[], context: z.Refine
|
|||||||
checks.forEach((check, index) => {
|
checks.forEach((check, index) => {
|
||||||
const label = `Check ${index + 1}`;
|
const label = `Check ${index + 1}`;
|
||||||
const needsSelector = check.type !== "visual-match";
|
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()) {
|
if (needsSelector && !check.selector?.trim()) {
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
@@ -73,7 +82,15 @@ function validateChecks(checks: z.infer<typeof checkSchema>[], 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({
|
context.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
path: ["checks", index, "attribute"],
|
path: ["checks", index, "attribute"],
|
||||||
@@ -81,7 +98,7 @@ function validateChecks(checks: z.infer<typeof checkSchema>[], 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({
|
context.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
path: ["checks", index, "property"],
|
path: ["checks", index, "property"],
|
||||||
@@ -89,7 +106,7 @@ function validateChecks(checks: z.infer<typeof checkSchema>[], 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({
|
context.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
path: ["checks", index, "count"],
|
path: ["checks", index, "count"],
|
||||||
@@ -367,11 +384,15 @@ async function maybeGenerateExpected(task: {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return generateExpectedScreenshot(task.id, {
|
return queueSequential(() => generateExpectedScreenshot(task.id, {
|
||||||
html: task.referenceHtml,
|
html: task.referenceHtml,
|
||||||
css: task.referenceCss,
|
css: task.referenceCss,
|
||||||
js: task.referenceJs
|
js: task.referenceJs
|
||||||
});
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueJudgeSubmission(options: Parameters<typeof judgeSubmission>[0]) {
|
||||||
|
return queueSequential(() => judgeSubmission(options));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function seedSampleData() {
|
async function seedSampleData() {
|
||||||
@@ -451,7 +472,7 @@ export async function createApp() {
|
|||||||
app.use("/generated", express.static(config.generatedDir));
|
app.use("/generated", express.static(config.generatedDir));
|
||||||
|
|
||||||
app.get("/api/health", (_req, res) => {
|
app.get("/api/health", (_req, res) => {
|
||||||
res.json({ ok: true });
|
res.json({ ok: true, queuedJobs: queuedJobCount() });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/auth/admin", (req, res) => {
|
app.post("/api/auth/admin", (req, res) => {
|
||||||
@@ -569,7 +590,7 @@ export async function createApp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const studentName = normalizeStudentName(parsed.data.studentName);
|
const studentName = normalizeStudentName(parsed.data.studentName);
|
||||||
const result = await judgeSubmission({
|
const result = await queueJudgeSubmission({
|
||||||
taskId: task.id,
|
taskId: task.id,
|
||||||
checksJson: task.checksJson,
|
checksJson: task.checksJson,
|
||||||
expectedScreenshotPath: task.expectedScreenshotPath,
|
expectedScreenshotPath: task.expectedScreenshotPath,
|
||||||
@@ -845,7 +866,7 @@ export async function createApp() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await judgeSubmission({
|
const result = await queueJudgeSubmission({
|
||||||
taskId: submission.taskId,
|
taskId: submission.taskId,
|
||||||
checksJson: submission.task.checksJson,
|
checksJson: submission.task.checksJson,
|
||||||
expectedScreenshotPath: submission.task.expectedScreenshotPath,
|
expectedScreenshotPath: submission.task.expectedScreenshotPath,
|
||||||
@@ -902,10 +923,16 @@ export async function createApp() {
|
|||||||
res.json({ url, markdown: `` });
|
res.json({ url, markdown: `` });
|
||||||
});
|
});
|
||||||
|
|
||||||
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.use(express.static(config.frontendDistDir));
|
||||||
app.get("*", (_req, res) => {
|
app.get("*", (_req, res) => {
|
||||||
res.sendFile(path.join(config.frontendDistDir, "index.html"));
|
res.sendFile(frontendIndexPath);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,20 @@ function checkWeight(check: CheckDefinition) {
|
|||||||
return typeof check.weight === "number" && Number.isFinite(check.weight) && check.weight > 0 ? check.weight : 1;
|
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<ReturnType<typeof renderScreenshot>>["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) {
|
export function summarizeWeightedResults(results: CheckResult[], passingScore = 0.8) {
|
||||||
const totalWeight = results.reduce((sum, result) => sum + (result.weight ?? 1), 0);
|
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);
|
const passedWeight = results.reduce((sum, result) => sum + (result.passed ? result.weight ?? 1 : 0), 0);
|
||||||
@@ -222,6 +236,97 @@ export async function judgeSubmission(options: {
|
|||||||
continue;
|
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) {
|
if (!check.selector) {
|
||||||
results.push({
|
results.push({
|
||||||
type: check.type,
|
type: check.type,
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
type Job<T> = () => Promise<T>;
|
||||||
|
|
||||||
|
let tail = Promise.resolve();
|
||||||
|
let pendingJobs = 0;
|
||||||
|
|
||||||
|
export function queueSequential<T>(job: Job<T>): Promise<T> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -4,6 +4,10 @@ export type CheckType =
|
|||||||
| "attribute-equals"
|
| "attribute-equals"
|
||||||
| "css-property"
|
| "css-property"
|
||||||
| "element-count"
|
| "element-count"
|
||||||
|
| "interaction-text-contains"
|
||||||
|
| "interaction-attribute-equals"
|
||||||
|
| "interaction-css-property"
|
||||||
|
| "interaction-element-count"
|
||||||
| "visual-match";
|
| "visual-match";
|
||||||
|
|
||||||
export type CheckDefinition = {
|
export type CheckDefinition = {
|
||||||
@@ -13,6 +17,9 @@ export type CheckDefinition = {
|
|||||||
value?: string;
|
value?: string;
|
||||||
attribute?: string;
|
attribute?: string;
|
||||||
property?: string;
|
property?: string;
|
||||||
|
actionSelector?: string;
|
||||||
|
inputSelector?: string;
|
||||||
|
inputValue?: string;
|
||||||
count?: number;
|
count?: number;
|
||||||
threshold?: number;
|
threshold?: number;
|
||||||
weight?: number;
|
weight?: number;
|
||||||
|
|||||||
+40
-4
@@ -34,6 +34,10 @@ type CheckType =
|
|||||||
| "attribute-equals"
|
| "attribute-equals"
|
||||||
| "css-property"
|
| "css-property"
|
||||||
| "element-count"
|
| "element-count"
|
||||||
|
| "interaction-text-contains"
|
||||||
|
| "interaction-attribute-equals"
|
||||||
|
| "interaction-css-property"
|
||||||
|
| "interaction-element-count"
|
||||||
| "visual-match";
|
| "visual-match";
|
||||||
|
|
||||||
type CheckDefinition = {
|
type CheckDefinition = {
|
||||||
@@ -42,6 +46,9 @@ type CheckDefinition = {
|
|||||||
value?: string;
|
value?: string;
|
||||||
attribute?: string;
|
attribute?: string;
|
||||||
property?: string;
|
property?: string;
|
||||||
|
actionSelector?: string;
|
||||||
|
inputSelector?: string;
|
||||||
|
inputValue?: string;
|
||||||
count?: number;
|
count?: number;
|
||||||
threshold?: number;
|
threshold?: number;
|
||||||
weight?: number;
|
weight?: number;
|
||||||
@@ -1149,6 +1156,24 @@ function CheckBuilder(props: {
|
|||||||
onChange: (index: number, patch: Partial<CheckDefinition>) => void;
|
onChange: (index: number, patch: Partial<CheckDefinition>) => void;
|
||||||
onDelete: (index: number) => 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 (
|
return (
|
||||||
<section className="checks">
|
<section className="checks">
|
||||||
<div className="section-heading">
|
<div className="section-heading">
|
||||||
@@ -1165,21 +1190,32 @@ function CheckBuilder(props: {
|
|||||||
<option value="attribute-equals">Attribute equals</option>
|
<option value="attribute-equals">Attribute equals</option>
|
||||||
<option value="css-property">CSS property</option>
|
<option value="css-property">CSS property</option>
|
||||||
<option value="element-count">Element count</option>
|
<option value="element-count">Element count</option>
|
||||||
|
<option value="interaction-text-contains">After click: text contains</option>
|
||||||
|
<option value="interaction-attribute-equals">After click: attribute equals</option>
|
||||||
|
<option value="interaction-css-property">After click: CSS property</option>
|
||||||
|
<option value="interaction-element-count">After click: element count</option>
|
||||||
<option value="visual-match">Visual match</option>
|
<option value="visual-match">Visual match</option>
|
||||||
</select>
|
</select>
|
||||||
{check.type !== "visual-match" && (
|
{check.type !== "visual-match" && (
|
||||||
<input value={check.selector ?? ""} onChange={(event) => props.onChange(index, { selector: event.target.value })} placeholder="selector" />
|
<input value={check.selector ?? ""} onChange={(event) => props.onChange(index, { selector: event.target.value })} placeholder="selector" />
|
||||||
)}
|
)}
|
||||||
{check.type === "attribute-equals" && (
|
{interactionCheckTypes.includes(check.type) && (
|
||||||
|
<>
|
||||||
|
<input value={check.actionSelector ?? ""} onChange={(event) => props.onChange(index, { actionSelector: event.target.value })} placeholder="click selector" />
|
||||||
|
<input value={check.inputSelector ?? ""} onChange={(event) => props.onChange(index, { inputSelector: event.target.value })} placeholder="input selector" />
|
||||||
|
<input value={check.inputValue ?? ""} onChange={(event) => props.onChange(index, { inputValue: event.target.value })} placeholder="input value" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{attributeCheckTypes.includes(check.type) && (
|
||||||
<input value={check.attribute ?? ""} onChange={(event) => props.onChange(index, { attribute: event.target.value })} placeholder="attribute" />
|
<input value={check.attribute ?? ""} onChange={(event) => props.onChange(index, { attribute: event.target.value })} placeholder="attribute" />
|
||||||
)}
|
)}
|
||||||
{check.type === "css-property" && (
|
{cssCheckTypes.includes(check.type) && (
|
||||||
<input value={check.property ?? ""} onChange={(event) => props.onChange(index, { property: event.target.value })} placeholder="property" />
|
<input value={check.property ?? ""} onChange={(event) => props.onChange(index, { property: event.target.value })} placeholder="property" />
|
||||||
)}
|
)}
|
||||||
{["text-contains", "attribute-equals", "css-property"].includes(check.type) && (
|
{valueCheckTypes.includes(check.type) && (
|
||||||
<input value={check.value ?? ""} onChange={(event) => props.onChange(index, { value: event.target.value })} placeholder="expected value" />
|
<input value={check.value ?? ""} onChange={(event) => props.onChange(index, { value: event.target.value })} placeholder="expected value" />
|
||||||
)}
|
)}
|
||||||
{check.type === "element-count" && (
|
{countCheckTypes.includes(check.type) && (
|
||||||
<input type="number" value={check.count ?? 1} onChange={(event) => props.onChange(index, { count: Number(event.target.value) })} />
|
<input type="number" value={check.count ?? 1} onChange={(event) => props.onChange(index, { count: Number(event.target.value) })} />
|
||||||
)}
|
)}
|
||||||
{check.type === "visual-match" && (
|
{check.type === "visual-match" && (
|
||||||
|
|||||||
@@ -314,6 +314,7 @@ p {
|
|||||||
grid-template-columns: minmax(360px, 1fr) minmax(320px, 42%);
|
grid-template-columns: minmax(360px, 1fr) minmax(320px, 42%);
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editors,
|
.editors,
|
||||||
@@ -327,6 +328,7 @@ p {
|
|||||||
.submission-detail {
|
.submission-detail {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.7rem;
|
gap: 0.7rem;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-section,
|
.editor-section,
|
||||||
@@ -340,6 +342,7 @@ p {
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1px solid #d8e0e8;
|
border: 1px solid #d8e0e8;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-title,
|
.section-title,
|
||||||
@@ -569,6 +572,7 @@ p {
|
|||||||
.submitted-code {
|
.submitted-code {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.7rem;
|
gap: 0.7rem;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.submitted-code h3 {
|
.submitted-code h3 {
|
||||||
@@ -657,11 +661,28 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.cm-editor {
|
.cm-editor {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
border: 1px solid #d8e0e8;
|
border: 1px solid #d8e0e8;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
overflow: hidden;
|
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) {
|
@media (max-width: 1050px) {
|
||||||
.student-layout,
|
.student-layout,
|
||||||
.admin-layout,
|
.admin-layout,
|
||||||
|
|||||||
Reference in New Issue
Block a user