Skip to content

Commit 58939c2

Browse files
reminjpautofix-ci[bot]WillBooster-Agent
authored
feat: helpers for web page problems (#39)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: WillBooster (Claude Code) <agent@willbooster.com>
1 parent 3ab57dc commit 58939c2

File tree

12 files changed

+926
-14
lines changed

12 files changed

+926
-14
lines changed

example/bun.lock

Lines changed: 130 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

example/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
"sideEffects": false,
77
"type": "module",
88
"dependencies": {
9-
"@exercode/problem-utils": "file:.."
9+
"@exercode/problem-utils": "file:..",
10+
"puppeteer": "24.34.0"
1011
},
1112
"devDependencies": {
1213
"@tsconfig/node24": "24.0.3",

example/tsconfig.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"extends": "@tsconfig/node24/tsconfig.json",
33
"compilerOptions": {
4+
"lib": ["dom"],
45
"noUncheckedIndexedAccess": true
56
},
67
"include": ["**/*.ts"]

example/web_page_weather/judge.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { DecisionCode, parseArgs, printTestCaseResult, startHttpServer } from '@exercode/problem-utils';
2+
import type { TestCaseResult } from '@exercode/problem-utils';
3+
import assert from 'node:assert';
4+
import puppeteer from 'puppeteer';
5+
import type { Page } from 'puppeteer';
6+
7+
const TEST_CASES: readonly [string, (page: Page) => Promise<Omit<TestCaseResult, 'testCaseId'>>][] = [
8+
[
9+
'01_h1',
10+
async (page) => {
11+
try {
12+
const h1Handle = await page.locator('h1').waitHandle();
13+
const h1Text = await h1Handle.evaluate((e) => e.textContent.trim());
14+
assert.strictEqual(h1Text, '今日の天気予報');
15+
} catch (error) {
16+
return {
17+
decisionCode: DecisionCode.WRONG_ANSWER,
18+
stderr: error instanceof Error ? error.message : String(error),
19+
feedbackMarkdown: '`h1`タグによる見出し`今日の天気予報`が見つかりません。',
20+
};
21+
}
22+
return { decisionCode: DecisionCode.ACCEPTED };
23+
},
24+
],
25+
[
26+
'02_hr',
27+
async (page) => {
28+
try {
29+
await page.locator('hr').waitHandle();
30+
} catch (error) {
31+
return {
32+
decisionCode: DecisionCode.WRONG_ANSWER,
33+
stderr: error instanceof Error ? error.message : String(error),
34+
feedbackMarkdown: '`hr`タグによる水平線が見つかりません。',
35+
};
36+
}
37+
return { decisionCode: DecisionCode.ACCEPTED };
38+
},
39+
],
40+
[
41+
'03_p',
42+
async (page) => {
43+
const requiredTexts = ['晴れ', '最高気温:25℃', '最低気温:18℃', '降水確率:0%'];
44+
45+
const pTexts = await page.$$eval('p', (es) => es.map((e) => e.textContent?.trim() ?? ''));
46+
47+
if (pTexts.length !== requiredTexts.length) {
48+
return {
49+
decisionCode: DecisionCode.WRONG_ANSWER,
50+
feedbackMarkdown: `\`p\`タグの件数が一致しません。\n${requiredTexts.length}件必要ですが、${pTexts.length}件見つかりました。`,
51+
};
52+
}
53+
54+
for (const [i, expected] of requiredTexts.entries()) {
55+
if (pTexts[i] === expected) continue;
56+
return {
57+
decisionCode: DecisionCode.WRONG_ANSWER,
58+
feedbackMarkdown: `\`p\`タグの内容が一致しません。\n${i + 1}番目には\`${expected}\`が期待されていますが、\`${pTexts[i]}\`が見つかりました。`,
59+
};
60+
}
61+
62+
return { decisionCode: DecisionCode.ACCEPTED };
63+
},
64+
],
65+
];
66+
67+
const args = parseArgs(process.argv);
68+
await using server = startHttpServer(args.cwd);
69+
70+
const browser = await puppeteer.launch({
71+
args: process.env.CI || process.env.WB_DOCKER === '1' ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
72+
});
73+
const page = await browser.newPage();
74+
page.setDefaultTimeout(1000);
75+
76+
await page.goto(server.url, { waitUntil: 'domcontentloaded' });
77+
78+
for (const [testCaseId, test] of TEST_CASES) {
79+
const result = await test(page);
80+
printTestCaseResult({ testCaseId, ...result });
81+
if (result.decisionCode !== DecisionCode.ACCEPTED) break;
82+
}
83+
84+
await browser.close();
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<!doctype html>
2+
<html lang="ja">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<title>今日の天気予報</title>
6+
</head>
7+
<body>
8+
<h1>今日の天気予報</h1>
9+
<hr />
10+
<p>晴れ</p>
11+
<p>最高気温:25℃</p>
12+
<p>最低気温:18℃</p>
13+
</body>
14+
</html>
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<!doctype html>
2+
<html lang="ja">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<title>今日の天気予報</title>
6+
</head>
7+
<body>
8+
<h1>今日の天気予報</h1>
9+
<hr />
10+
<p>晴れ</p>
11+
<p>最高気温:25℃</p>
12+
<p>最低気温:18℃</p>
13+
<p>降水確率:0%</p>
14+
</body>
15+
</html>
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
name: '天気予報'
3+
timeLimitMs: 10000
4+
---
5+
6+
次の内容を順に含むウェブページをHTMLで作成してください。
7+
8+
1. `h1`タグによる見出し`今日の天気予報`
9+
2. `hr`タグによる水平線
10+
3. `p`タグによるテキスト
11+
1. `晴れ`
12+
2. `最高気温:25℃`(コロン(:)は全角であることに注意)
13+
3. `最低気温:18℃`
14+
4. `降水確率:0%`

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@
7979
"pinst": "3.0.0",
8080
"prettier": "3.7.4",
8181
"prettier-plugin-java": "2.7.7",
82+
"puppeteer": "24.34.0",
8283
"semantic-release": "25.0.2",
8384
"sort-package-json": "3.6.0",
8485
"typescript": "5.9.3",

src/helpers/startHttpServer.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import fs from 'node:fs';
2+
import http from 'node:http';
3+
import path from 'node:path';
4+
5+
export interface HttpServer {
6+
[Symbol.asyncDispose](): Promise<void>;
7+
url: string;
8+
port: number | undefined;
9+
}
10+
11+
/**
12+
* Start a HTTP server for testing web pages.
13+
*/
14+
export function startHttpServer(dir: string): HttpServer {
15+
const server = http.createServer((request, response) => {
16+
let pathname = new URL(request.url ?? '/', 'http://127.0.0.1').pathname;
17+
18+
if (pathname === '/') {
19+
pathname = '/index.html';
20+
}
21+
22+
const pathnameWithIndexHtml = pathname.endsWith('/') ? path.join(pathname, 'index.html') : pathname;
23+
24+
const filePath = path.join(dir, pathnameWithIndexHtml);
25+
26+
if (fs.existsSync(filePath)) {
27+
try {
28+
const buffer = fs.readFileSync(filePath);
29+
response.writeHead(200, {
30+
'Access-Control-Allow-Origin': '*',
31+
Pragma: 'no-cache',
32+
'Cache-Control': 'no-cache',
33+
'Content-Type': getContentType(pathnameWithIndexHtml),
34+
});
35+
response.end(buffer);
36+
} catch {
37+
response.statusCode = 500;
38+
response.end();
39+
}
40+
} else {
41+
response.statusCode = 404;
42+
response.end();
43+
}
44+
});
45+
46+
server.listen();
47+
48+
const address = server.address();
49+
if (!address) throw new Error('server has been unexpectedly closed');
50+
51+
return {
52+
[Symbol.asyncDispose]: async () => {
53+
server.closeAllConnections();
54+
await new Promise<void>((resolve) => {
55+
server.close(() => {
56+
resolve();
57+
});
58+
});
59+
},
60+
url: typeof address === 'string' ? address : `http://127.0.0.1:${address.port}`,
61+
port: typeof address === 'object' ? address.port : undefined,
62+
};
63+
}
64+
65+
const CONTENT_TYPE_BY_SUFFIX: Record<string, string> = {
66+
'.css': 'text/css',
67+
'.html': 'text/html',
68+
'.js': 'text/javascript',
69+
'.gif': 'image/gif',
70+
'.jpg': 'image/jpeg',
71+
'.png': 'image/png',
72+
'.svg': 'image/svg+xml',
73+
};
74+
75+
function getContentType(pathname: string): string {
76+
for (const [suffix, contentType] of Object.entries(CONTENT_TYPE_BY_SUFFIX)) {
77+
if (pathname.endsWith(suffix)) return contentType;
78+
}
79+
return 'text/plain';
80+
}

src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,5 @@
1+
export * from './helpers/parseArgs.js';
2+
export * from './helpers/printTestCaseResult.js';
3+
export * from './helpers/startHttpServer.js';
14
export * from './types/decisionCode.js';
25
export * from './types/testCaseResult.js';

0 commit comments

Comments
 (0)