atcoder-workspace 1.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +98 -0
  3. package/THIRD_PARTY_LICENSES +21 -0
  4. package/dist/atcoder/client.d.ts +2 -0
  5. package/dist/atcoder/client.js +23 -0
  6. package/dist/atcoder/new.d.ts +15 -0
  7. package/dist/atcoder/new.js +123 -0
  8. package/dist/atcoder/parser/contest-tasks.d.ts +6 -0
  9. package/dist/atcoder/parser/contest-tasks.js +86 -0
  10. package/dist/atcoder/parser/limits.d.ts +10 -0
  11. package/dist/atcoder/parser/limits.js +71 -0
  12. package/dist/atcoder/parser/problem-page.d.ts +12 -0
  13. package/dist/atcoder/parser/problem-page.js +136 -0
  14. package/dist/atcoder/parser/submission-status.d.ts +8 -0
  15. package/dist/atcoder/parser/submission-status.js +78 -0
  16. package/dist/atcoder/submit.d.ts +9 -0
  17. package/dist/atcoder/submit.js +182 -0
  18. package/dist/cli.d.ts +2 -0
  19. package/dist/cli.js +508 -0
  20. package/dist/config/config-store.d.ts +17 -0
  21. package/dist/config/config-store.js +92 -0
  22. package/dist/session/auth.d.ts +8 -0
  23. package/dist/session/auth.js +117 -0
  24. package/dist/session/store.d.ts +15 -0
  25. package/dist/session/store.js +75 -0
  26. package/dist/test-runner/diff.d.ts +7 -0
  27. package/dist/test-runner/diff.js +32 -0
  28. package/dist/test-runner/runner.d.ts +46 -0
  29. package/dist/test-runner/runner.js +274 -0
  30. package/dist/utils/errors.d.ts +15 -0
  31. package/dist/utils/errors.js +35 -0
  32. package/dist/utils/format.d.ts +9 -0
  33. package/dist/utils/format.js +89 -0
  34. package/dist/utils/i18n.d.ts +345 -0
  35. package/dist/utils/i18n.js +413 -0
  36. package/dist/utils/open.d.ts +8 -0
  37. package/dist/utils/open.js +50 -0
  38. package/dist/workspace/finder.d.ts +9 -0
  39. package/dist/workspace/finder.js +62 -0
  40. package/dist/workspace/initializer.d.ts +4 -0
  41. package/dist/workspace/initializer.js +109 -0
  42. package/package.json +38 -0
  43. package/src/atcoder/client.ts +21 -0
  44. package/src/atcoder/new.ts +107 -0
  45. package/src/atcoder/parser/contest-tasks.test.ts +37 -0
  46. package/src/atcoder/parser/contest-tasks.ts +61 -0
  47. package/src/atcoder/parser/limits.test.ts +52 -0
  48. package/src/atcoder/parser/limits.ts +75 -0
  49. package/src/atcoder/parser/problem-page.test.ts +68 -0
  50. package/src/atcoder/parser/problem-page.ts +126 -0
  51. package/src/atcoder/parser/submission-status.test.ts +36 -0
  52. package/src/atcoder/parser/submission-status.ts +54 -0
  53. package/src/atcoder/submit.ts +170 -0
  54. package/src/cli.ts +554 -0
  55. package/src/config/config-store.ts +72 -0
  56. package/src/session/auth.ts +87 -0
  57. package/src/session/store.ts +50 -0
  58. package/src/test-runner/diff.test.ts +26 -0
  59. package/src/test-runner/diff.ts +42 -0
  60. package/src/test-runner/runner.test.ts +70 -0
  61. package/src/test-runner/runner.ts +315 -0
  62. package/src/utils/errors.ts +31 -0
  63. package/src/utils/format.test.ts +69 -0
  64. package/src/utils/format.ts +95 -0
  65. package/src/utils/i18n.test.ts +74 -0
  66. package/src/utils/i18n.ts +418 -0
  67. package/src/utils/open.ts +47 -0
  68. package/src/workspace/finder.ts +29 -0
  69. package/src/workspace/initializer.ts +85 -0
  70. package/tsconfig.json +16 -0
@@ -0,0 +1,87 @@
1
+ import * as cheerio from 'cheerio';
2
+ import { saveSession, loadSession, SavedCookie } from './store';
3
+ import { createAtCoderClient } from '../atcoder/client';
4
+ import { AuthError } from '../utils/errors';
5
+
6
+ /**
7
+ * Saves a manually entered REVEL_SESSION cookie and verifies the session.
8
+ */
9
+ export async function loginWithCookie(workspaceRoot: string, revelSession: string): Promise<string> {
10
+ let cleanSession = revelSession.trim();
11
+ if (!cleanSession) {
12
+ throw new AuthError('REVEL_SESSION cookie value cannot be empty.');
13
+ }
14
+
15
+ if (cleanSession.startsWith('REVEL_SESSION=')) {
16
+ cleanSession = cleanSession.substring('REVEL_SESSION='.length);
17
+ }
18
+ cleanSession = cleanSession.split(';')[0].trim();
19
+
20
+ if (!cleanSession) {
21
+ throw new AuthError('Could not extract a valid REVEL_SESSION value.');
22
+ }
23
+
24
+ const savedCookies: SavedCookie[] = [
25
+ {
26
+ name: 'REVEL_SESSION',
27
+ value: cleanSession,
28
+ domain: '.atcoder.jp',
29
+ path: '/',
30
+ expires: Math.floor(Date.now() / 1000) + 86400 * 30,
31
+ httpOnly: true,
32
+ secure: true,
33
+ sameSite: 'Lax'
34
+ }
35
+ ];
36
+
37
+ saveSession(workspaceRoot, savedCookies);
38
+
39
+ try {
40
+ const username = await whoami(workspaceRoot);
41
+ return username;
42
+ } catch (err: any) {
43
+ try {
44
+ const { clearSession } = require('./store');
45
+ clearSession(workspaceRoot);
46
+ } catch (e) {}
47
+ throw new AuthError(`The provided REVEL_SESSION cookie is invalid or expired: ${err.message}`);
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Checks the login status by requesting the AtCoder settings page with saved session cookies.
53
+ */
54
+ export async function whoami(workspaceRoot: string): Promise<string> {
55
+ const session = loadSession(workspaceRoot);
56
+ if (!session || !session.some(c => c.name === 'REVEL_SESSION')) {
57
+ throw new AuthError('No active session. Please log in using "atc login".');
58
+ }
59
+
60
+ const client = createAtCoderClient(workspaceRoot);
61
+ try {
62
+ const res = await client.get('/settings');
63
+ const $ = cheerio.load(res.data);
64
+
65
+ const userLink = $('header a[href^="/users/"], .navbar-right a[href^="/users/"]').first();
66
+ const href = userLink.attr('href');
67
+ if (href) {
68
+ const match = href.match(/\/users\/([a-zA-Z0-9_]+)/);
69
+ if (match && match[1]) {
70
+ return match[1];
71
+ }
72
+ }
73
+
74
+ const usernameSpan = $('header a.username, header .dropdown-toggle').first();
75
+ if (usernameSpan.length) {
76
+ const name = usernameSpan.text().replace(/\s+/g, ' ').trim();
77
+ if (name && name !== 'Sign In' && name !== 'ログイン' && name !== 'Sign Up' && name !== '新規登録') {
78
+ return name;
79
+ }
80
+ }
81
+
82
+ throw new AuthError('Session is invalid or expired.');
83
+ } catch (err: any) {
84
+ if (err instanceof AuthError) throw err;
85
+ throw new AuthError(`Failed to verify session: ${err.message}`);
86
+ }
87
+ }
@@ -0,0 +1,50 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+
4
+ export interface SavedCookie {
5
+ name: string;
6
+ value: string;
7
+ domain: string;
8
+ path: string;
9
+ expires: number;
10
+ httpOnly: boolean;
11
+ secure: boolean;
12
+ sameSite: 'Strict' | 'Lax' | 'None';
13
+ }
14
+
15
+ export function getSessionPath(workspaceRoot: string): string {
16
+ return path.join(workspaceRoot, '.atcoder-cli', 'session.json');
17
+ }
18
+
19
+ export function saveSession(workspaceRoot: string, cookies: SavedCookie[]): void {
20
+ const sessionPath = getSessionPath(workspaceRoot);
21
+ const dir = path.dirname(sessionPath);
22
+ if (!fs.existsSync(dir)) {
23
+ fs.mkdirSync(dir, { recursive: true });
24
+ }
25
+ fs.writeFileSync(sessionPath, JSON.stringify(cookies, null, 2), 'utf8');
26
+ }
27
+
28
+ export function loadSession(workspaceRoot: string): SavedCookie[] | null {
29
+ const sessionPath = getSessionPath(workspaceRoot);
30
+ if (!fs.existsSync(sessionPath)) {
31
+ return null;
32
+ }
33
+ try {
34
+ const raw = fs.readFileSync(sessionPath, 'utf8');
35
+ return JSON.parse(raw) as SavedCookie[];
36
+ } catch (e) {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ export function clearSession(workspaceRoot: string): void {
42
+ const sessionPath = getSessionPath(workspaceRoot);
43
+ if (fs.existsSync(sessionPath)) {
44
+ fs.unlinkSync(sessionPath);
45
+ }
46
+ }
47
+
48
+ export function getCookieHeaderString(cookies: SavedCookie[]): string {
49
+ return cookies.map(c => `${c.name}=${c.value}`).join('; ');
50
+ }
@@ -0,0 +1,26 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { compareOutput } from './diff';
3
+
4
+ describe('compareOutput', () => {
5
+ it('should match identical strings', () => {
6
+ const res = compareOutput('hello\nworld', 'hello\nworld');
7
+ expect(res.isMatch).toBe(true);
8
+ });
9
+
10
+ it('should ignore trailing space and newlines', () => {
11
+ const res = compareOutput('hello \nworld\n\n', 'hello\nworld');
12
+ expect(res.isMatch).toBe(true);
13
+ });
14
+
15
+ it('should detect mismatch and identify line number', () => {
16
+ const res = compareOutput('hello\nworld', 'hello\nthere');
17
+ expect(res.isMatch).toBe(false);
18
+ expect(res.firstDiffLine).toBe(2);
19
+ });
20
+
21
+ it('should handle extra lines', () => {
22
+ const res = compareOutput('hello\nworld\nmore', 'hello\nworld');
23
+ expect(res.isMatch).toBe(false);
24
+ expect(res.firstDiffLine).toBe(3);
25
+ });
26
+ });
@@ -0,0 +1,42 @@
1
+ export interface CompareResult {
2
+ isMatch: boolean;
3
+ actualNormalized: string;
4
+ expectedNormalized: string;
5
+ firstDiffLine?: number; // 1-indexed
6
+ }
7
+
8
+ export function compareOutput(actual: string, expected: string): CompareResult {
9
+ const actualLines = normalizeAndSplit(actual);
10
+ const expectedLines = normalizeAndSplit(expected);
11
+
12
+ let isMatch = true;
13
+ let firstDiffLine: number | undefined;
14
+
15
+ const maxLines = Math.max(actualLines.length, expectedLines.length);
16
+ for (let i = 0; i < maxLines; i++) {
17
+ if (actualLines[i] !== expectedLines[i]) {
18
+ isMatch = false;
19
+ firstDiffLine = i + 1;
20
+ break;
21
+ }
22
+ }
23
+
24
+ return {
25
+ isMatch,
26
+ actualNormalized: actualLines.join('\n'),
27
+ expectedNormalized: expectedLines.join('\n'),
28
+ firstDiffLine
29
+ };
30
+ }
31
+
32
+ function normalizeAndSplit(str: string): string[] {
33
+ const lines = str.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');
34
+ const trimmed = lines.map(line => line.trimEnd());
35
+
36
+ // Remove trailing empty lines
37
+ while (trimmed.length > 0 && trimmed[trimmed.length - 1] === '') {
38
+ trimmed.pop();
39
+ }
40
+
41
+ return trimmed;
42
+ }
@@ -0,0 +1,70 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import * as path from 'path';
3
+ import * as fs from 'fs';
4
+ import { resolveCommands, resolveTaskDirectory } from './runner';
5
+ import * as configStore from '../config/config-store';
6
+
7
+ vi.mock('fs', async (importOriginal) => {
8
+ const actual = await importOriginal<typeof import('fs')>();
9
+ return {
10
+ ...actual,
11
+ existsSync: vi.fn((p: any) => {
12
+ if (p.toString().endsWith('my-contests/abc300/a')) {
13
+ return true;
14
+ }
15
+ return actual.existsSync(p);
16
+ }),
17
+ statSync: vi.fn((p: any) => {
18
+ if (p.toString().endsWith('my-contests/abc300/a')) {
19
+ return { isDirectory: () => true } as any;
20
+ }
21
+ return actual.statSync(p);
22
+ }),
23
+ };
24
+ });
25
+
26
+ describe('runner utils', () => {
27
+ describe('resolveCommands', () => {
28
+ it('should substitute template file names correctly', () => {
29
+ const langConfig = {
30
+ extension: 'cpp',
31
+ templateDir: 'templates/cpp',
32
+ build: 'g++ -O2 -std=gnu++20 -o a.out main.cpp',
33
+ run: './a.out'
34
+ };
35
+
36
+ const resolved = resolveCommands('/workspace', langConfig, 'sol.cpp', 'cpp');
37
+ expect(resolved.build).toBe('g++ -O2 -std=gnu++20 -o a.out sol.cpp');
38
+ expect(resolved.run).toBe('./a.out');
39
+ });
40
+
41
+ it('should handle languages without build commands', () => {
42
+ const langConfig = {
43
+ extension: 'py',
44
+ templateDir: 'templates/python',
45
+ build: '',
46
+ run: 'python3 main.py'
47
+ };
48
+
49
+ const resolved = resolveCommands('/workspace', langConfig, 'sol.py', 'py');
50
+ expect(resolved.build).toBe('');
51
+ expect(resolved.run).toBe('python3 sol.py');
52
+ });
53
+ });
54
+
55
+ describe('resolveTaskDirectory', () => {
56
+ it('should resolve task directory under configured contestDir', () => {
57
+ const loadConfigSpy = vi.spyOn(configStore, 'loadConfig').mockReturnValue({
58
+ defaultLanguage: 'cpp',
59
+ languages: {},
60
+ testDirName: 'tests',
61
+ contestDir: 'my-contests'
62
+ });
63
+
64
+ const resolved = resolveTaskDirectory('/workspace', 'abc300/a');
65
+ expect(resolved).toContain('my-contests/abc300/a');
66
+
67
+ loadConfigSpy.mockRestore();
68
+ });
69
+ });
70
+ });
@@ -0,0 +1,315 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { spawn, exec } from 'child_process';
4
+ import { findWorkspaceRoot } from '../workspace/finder';
5
+ import { loadConfig, Config, LanguageConfig } from '../config/config-store';
6
+ import { compareOutput } from './diff';
7
+ import { AtcError } from '../utils/errors';
8
+
9
+ export interface TestCaseResult {
10
+ index: number;
11
+ status: 'AC' | 'WA' | 'TLE' | 'RE';
12
+ durationMs: number;
13
+ actualOutput: string;
14
+ expectedOutput: string;
15
+ errorOutput?: string;
16
+ firstDiffLine?: number;
17
+ }
18
+
19
+ export interface RunAllTestsResult {
20
+ success: boolean;
21
+ compileError?: string;
22
+ results: TestCaseResult[];
23
+ }
24
+
25
+ /**
26
+ * Resolves the absolute path to a task directory.
27
+ */
28
+ export function resolveTaskDirectory(workspaceRoot: string, taskArg?: string): string {
29
+ const cwd = process.cwd();
30
+
31
+ if (taskArg) {
32
+ const pathFromCwd = path.resolve(cwd, taskArg);
33
+ if (fs.existsSync(pathFromCwd) && fs.statSync(pathFromCwd).isDirectory()) {
34
+ return pathFromCwd;
35
+ }
36
+
37
+ const pathFromRoot = path.resolve(workspaceRoot, taskArg);
38
+ if (fs.existsSync(pathFromRoot) && fs.statSync(pathFromRoot).isDirectory()) {
39
+ return pathFromRoot;
40
+ }
41
+
42
+ const config = loadConfig(workspaceRoot);
43
+ if (config.contestDir) {
44
+ const pathFromConfigDir = path.resolve(workspaceRoot, config.contestDir, taskArg);
45
+ if (fs.existsSync(pathFromConfigDir) && fs.statSync(pathFromConfigDir).isDirectory()) {
46
+ return pathFromConfigDir;
47
+ }
48
+ }
49
+
50
+ const labelPath = path.join(cwd, taskArg);
51
+ if (fs.existsSync(labelPath) && fs.statSync(labelPath).isDirectory()) {
52
+ return labelPath;
53
+ }
54
+
55
+ throw new AtcError(`Task directory "${taskArg}" not found.`);
56
+ }
57
+
58
+ if (path.resolve(cwd) === path.resolve(workspaceRoot)) {
59
+ throw new AtcError('You are in the workspace root. Please specify a task directory (e.g., "atc test abc300/a").');
60
+ }
61
+
62
+ return cwd;
63
+ }
64
+
65
+ /**
66
+ * Detects the code file to execute in the task directory.
67
+ */
68
+ export function detectCodeFile(
69
+ workspaceRoot: string,
70
+ taskDir: string,
71
+ config: Config,
72
+ fileArg?: string
73
+ ): { codeFile: string; langKey: string; langConfig: LanguageConfig } {
74
+ if (fileArg) {
75
+ const fullPath = path.resolve(taskDir, fileArg);
76
+ if (!fs.existsSync(fullPath)) {
77
+ throw new AtcError(`Specified source file "${fileArg}" not found in "${taskDir}"`);
78
+ }
79
+
80
+ const ext = path.extname(fileArg).slice(1);
81
+ for (const [key, langConfig] of Object.entries(config.languages)) {
82
+ if (langConfig.extension === ext) {
83
+ return { codeFile: fileArg, langKey: key, langConfig };
84
+ }
85
+ }
86
+ throw new AtcError(`No language configuration found for file extension ".${ext}"`);
87
+ }
88
+
89
+ const files = fs.readdirSync(taskDir);
90
+
91
+ const defLang = config.languages[config.defaultLanguage];
92
+ if (defLang) {
93
+ const matchedFile = files.find(f => f.endsWith(`.${defLang.extension}`) && fs.statSync(path.join(taskDir, f)).isFile());
94
+ if (matchedFile) {
95
+ return { codeFile: matchedFile, langKey: config.defaultLanguage, langConfig: defLang };
96
+ }
97
+ }
98
+
99
+ for (const [key, langConfig] of Object.entries(config.languages)) {
100
+ if (key === config.defaultLanguage) continue;
101
+ const matchedFile = files.find(f => f.endsWith(`.${langConfig.extension}`) && fs.statSync(path.join(taskDir, f)).isFile());
102
+ if (matchedFile) {
103
+ return { codeFile: matchedFile, langKey: key, langConfig };
104
+ }
105
+ }
106
+
107
+ throw new AtcError(`No source files found in "${taskDir}" matching configured languages.`);
108
+ }
109
+
110
+ /**
111
+ * Resolves the build and run commands by substituting template filenames with the actual filename.
112
+ */
113
+ export function resolveCommands(
114
+ workspaceRoot: string,
115
+ langConfig: LanguageConfig,
116
+ actualFile: string,
117
+ extension: string
118
+ ): { build: string; run: string } {
119
+ const templateDir = langConfig.templateDir;
120
+ let templateFileName = `main.${extension}`;
121
+
122
+ const fullTemplatePath = path.join(workspaceRoot, '.atcoder-cli', templateDir);
123
+ if (fs.existsSync(fullTemplatePath) && fs.statSync(fullTemplatePath).isDirectory()) {
124
+ const files = fs.readdirSync(fullTemplatePath);
125
+ const matched = files.find(f => f.endsWith(`.${extension}`));
126
+ if (matched) {
127
+ templateFileName = matched;
128
+ }
129
+ }
130
+
131
+ const escapedTemplateName = templateFileName.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
132
+ const regex = new RegExp(escapedTemplateName, 'g');
133
+
134
+ const resolvedBuild = langConfig.build.replace(regex, actualFile);
135
+ const resolvedRun = langConfig.run.replace(regex, actualFile);
136
+
137
+ return {
138
+ build: resolvedBuild,
139
+ run: resolvedRun
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Runs the build command if present.
145
+ */
146
+ export function runBuild(buildCommand: string, taskDir: string): Promise<{ code: number; stderr: string }> {
147
+ return new Promise((resolve) => {
148
+ exec(buildCommand, { cwd: taskDir }, (err, stdout, stderr) => {
149
+ resolve({
150
+ code: err ? (err.code ?? 1) : 0,
151
+ stderr: stderr || stdout
152
+ });
153
+ });
154
+ });
155
+ }
156
+
157
+ /**
158
+ * Runs a single test case.
159
+ */
160
+ export function runTestCase(
161
+ runCommand: string,
162
+ taskDir: string,
163
+ inputPath: string,
164
+ outputPath: string,
165
+ timeLimitMs: number,
166
+ index: number
167
+ ): Promise<TestCaseResult> {
168
+ return new Promise((resolve) => {
169
+ const input = fs.readFileSync(inputPath, 'utf8');
170
+ const expected = fs.readFileSync(outputPath, 'utf8');
171
+
172
+ const startTime = process.hrtime.bigint();
173
+
174
+ const child = spawn(runCommand, {
175
+ cwd: taskDir,
176
+ shell: true
177
+ });
178
+
179
+ let stdout = '';
180
+ let stderr = '';
181
+ let killedByTimeout = false;
182
+
183
+ const timer = setTimeout(() => {
184
+ killedByTimeout = true;
185
+ child.kill('SIGKILL');
186
+ }, timeLimitMs);
187
+
188
+ child.stdout.on('data', (data) => {
189
+ stdout += data.toString();
190
+ });
191
+
192
+ child.stderr.on('data', (data) => {
193
+ stderr += data.toString();
194
+ });
195
+
196
+ child.on('error', (err) => {
197
+ clearTimeout(timer);
198
+ const endTime = process.hrtime.bigint();
199
+ const durationMs = Number(endTime - startTime) / 1e6;
200
+ resolve({
201
+ index,
202
+ status: 'RE',
203
+ durationMs,
204
+ actualOutput: stdout,
205
+ expectedOutput: expected,
206
+ errorOutput: err.message
207
+ });
208
+ });
209
+
210
+ child.on('exit', (code, signal) => {
211
+ clearTimeout(timer);
212
+ const endTime = process.hrtime.bigint();
213
+ const durationMs = Number(endTime - startTime) / 1e6;
214
+
215
+ if (killedByTimeout || signal === 'SIGKILL') {
216
+ resolve({
217
+ index,
218
+ status: 'TLE',
219
+ durationMs,
220
+ actualOutput: stdout,
221
+ expectedOutput: expected,
222
+ errorOutput: 'Time Limit Exceeded'
223
+ });
224
+ return;
225
+ }
226
+
227
+ if (code !== 0) {
228
+ resolve({
229
+ index,
230
+ status: 'RE',
231
+ durationMs,
232
+ actualOutput: stdout,
233
+ expectedOutput: expected,
234
+ errorOutput: stderr || `Exit code ${code}`
235
+ });
236
+ return;
237
+ }
238
+
239
+ const comp = compareOutput(stdout, expected);
240
+ resolve({
241
+ index,
242
+ status: comp.isMatch ? 'AC' : 'WA',
243
+ durationMs,
244
+ actualOutput: stdout,
245
+ expectedOutput: expected,
246
+ firstDiffLine: comp.firstDiffLine
247
+ });
248
+ });
249
+
250
+ child.stdin.write(input);
251
+ child.stdin.end();
252
+ });
253
+ }
254
+
255
+ export async function runAllTests(
256
+ workspaceRoot: string,
257
+ taskDir: string,
258
+ fileArg?: string,
259
+ timeLimitMs: number = 2000
260
+ ): Promise<RunAllTestsResult> {
261
+ const config = loadConfig(workspaceRoot);
262
+ const { codeFile, langConfig } = detectCodeFile(workspaceRoot, taskDir, config, fileArg);
263
+ const { build, run } = resolveCommands(workspaceRoot, langConfig, codeFile, langConfig.extension);
264
+
265
+ if (build.trim() !== '') {
266
+ const buildRes = await runBuild(build, taskDir);
267
+ if (buildRes.code !== 0) {
268
+ return {
269
+ success: false,
270
+ compileError: buildRes.stderr,
271
+ results: []
272
+ };
273
+ }
274
+ }
275
+
276
+ const testDirName = config.testDirName || 'tests';
277
+ const testDir = path.join(taskDir, testDirName);
278
+
279
+ if (!fs.existsSync(testDir) || !fs.statSync(testDir).isDirectory()) {
280
+ throw new AtcError(`Test directory "${testDir}" not found.`);
281
+ }
282
+
283
+ const files = fs.readdirSync(testDir);
284
+ const inFiles = files.filter(f => f.startsWith('sample-') && f.endsWith('.in'));
285
+
286
+ const results: TestCaseResult[] = [];
287
+
288
+ inFiles.sort((a, b) => {
289
+ const aIdx = parseInt(a.match(/sample-(\d+)\.in/)![1], 10);
290
+ const bIdx = parseInt(b.match(/sample-(\d+)\.in/)![1], 10);
291
+ return aIdx - bIdx;
292
+ });
293
+
294
+ for (const inFile of inFiles) {
295
+ const match = inFile.match(/sample-(\d+)\.in/);
296
+ if (!match) continue;
297
+ const index = parseInt(match[1], 10);
298
+ const inputPath = path.join(testDir, inFile);
299
+ const outputPath = path.join(testDir, `sample-${index}.out`);
300
+
301
+ if (!fs.existsSync(outputPath)) {
302
+ continue;
303
+ }
304
+
305
+ const testRes = await runTestCase(run, taskDir, inputPath, outputPath, timeLimitMs, index);
306
+ results.push(testRes);
307
+ }
308
+
309
+ const success = results.length > 0 && results.every(r => r.status === 'AC');
310
+
311
+ return {
312
+ success,
313
+ results
314
+ };
315
+ }
@@ -0,0 +1,31 @@
1
+ export class AtcError extends Error {
2
+ constructor(message: string) {
3
+ super(message);
4
+ this.name = this.constructor.name;
5
+ Error.captureStackTrace(this, this.constructor);
6
+ }
7
+ }
8
+
9
+ export class WorkspaceNotFoundError extends AtcError {
10
+ constructor() {
11
+ super('.atcoder-cli/ directory was not found. Please run "atc init" in your workspace root first.');
12
+ }
13
+ }
14
+
15
+ export class AuthError extends AtcError {
16
+ constructor(message: string) {
17
+ super(`Authentication failed: ${message}`);
18
+ }
19
+ }
20
+
21
+ export class ParseError extends AtcError {
22
+ constructor(message: string) {
23
+ super(`Parsing failed: ${message}`);
24
+ }
25
+ }
26
+
27
+ export class TestError extends AtcError {
28
+ constructor(message: string) {
29
+ super(`Test execution failed: ${message}`);
30
+ }
31
+ }
@@ -0,0 +1,69 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { formatOutputLines, formatErrorOutputLines } from './format';
3
+ import pc from 'picocolors';
4
+
5
+ describe('format utilities', () => {
6
+ describe('formatOutputLines', () => {
7
+ it('should format normal lines with line numbers and gray borders', () => {
8
+ const output = 'line1\nline2\n';
9
+ const formatted = formatOutputLines(output);
10
+
11
+ expect(formatted).toHaveLength(2);
12
+ expect(formatted[0]).toContain('1');
13
+ expect(formatted[0]).toContain('line1');
14
+ expect(formatted[1]).toContain('2');
15
+ expect(formatted[1]).toContain('line2');
16
+ });
17
+
18
+ it('should highlight the mismatch line if specified', () => {
19
+ const output = 'line1\nline2\nline3';
20
+ const formatted = formatOutputLines(output, 2);
21
+
22
+ expect(formatted).toHaveLength(3);
23
+ // Line 2 should be highlighted
24
+ expect(formatted[1]).toContain('>');
25
+ // Check if it has color escape codes (or contains the yellow color function output)
26
+ expect(formatted[1]).toContain(pc.yellow('line2'));
27
+ });
28
+
29
+ it('should handle empty/null outputs gracefully', () => {
30
+ expect(formatOutputLines('')).toEqual([` ${pc.gray('│')} ${pc.dim('(empty)')}`]);
31
+ expect(formatOutputLines(null as any)).toEqual([` ${pc.gray('│')} ${pc.dim('(no output)')}`]);
32
+ });
33
+
34
+ it('should omit the > 1 prefix when there is only one line in the output', () => {
35
+ const output = 'singleLine';
36
+ const formatted = formatOutputLines(output, 1);
37
+
38
+ expect(formatted).toHaveLength(1);
39
+ expect(formatted[0]).not.toContain('>');
40
+ expect(formatted[0]).not.toContain('1');
41
+ expect(formatted[0]).toContain(pc.yellow('singleLine'));
42
+ });
43
+
44
+ it('should truncate outputs if they exceed the max display size', () => {
45
+ const output = Array.from({ length: 50 }, (_, i) => `line ${i + 1}`).join('\n');
46
+ const formatted = formatOutputLines(output, 25);
47
+
48
+ // Should contain truncation indicator
49
+ const hasTruncatedBefore = formatted.some(l => l.includes('truncated'));
50
+ expect(hasTruncatedBefore).toBe(true);
51
+ });
52
+ });
53
+
54
+ describe('formatErrorOutputLines', () => {
55
+ it('should format error lines with red color and gray borders', () => {
56
+ const errorOutput = 'error line 1\nerror line 2';
57
+ const formatted = formatErrorOutputLines(errorOutput);
58
+
59
+ expect(formatted).toHaveLength(2);
60
+ expect(formatted[0]).toContain(pc.red('error line 1'));
61
+ expect(formatted[1]).toContain(pc.red('error line 2'));
62
+ });
63
+
64
+ it('should return empty array for empty inputs', () => {
65
+ expect(formatErrorOutputLines('')).toEqual([]);
66
+ expect(formatErrorOutputLines(null as any)).toEqual([]);
67
+ });
68
+ });
69
+ });