|
1 | | -import { readFile } from 'node:fs/promises'; |
| 1 | +import { readFile, readdir, stat } from 'node:fs/promises'; |
2 | 2 | import path from 'node:path'; |
3 | 3 | import fg from 'fast-glob'; |
4 | 4 | import { parse as parseYaml } from 'yaml'; |
@@ -158,6 +158,88 @@ export async function resolveFileReference( |
158 | 158 | return loadCasesFromFile(absolutePattern); |
159 | 159 | } |
160 | 160 |
|
| 161 | +/** |
| 162 | + * Load test cases from a directory structure. |
| 163 | + * Scans immediate subdirectories for case.yaml/case.yml files. |
| 164 | + * Each subdirectory becomes a test case, with the directory name used as `id` |
| 165 | + * if the case file doesn't specify one. A `workspace/` subdirectory in the |
| 166 | + * case directory sets the workspace template automatically. |
| 167 | + */ |
| 168 | +export async function loadCasesFromDirectory(dirPath: string): Promise<JsonObject[]> { |
| 169 | + const entries = await readdir(dirPath, { withFileTypes: true }); |
| 170 | + const subdirs = entries |
| 171 | + .filter((e) => e.isDirectory()) |
| 172 | + .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); |
| 173 | + |
| 174 | + const results: JsonObject[] = []; |
| 175 | + for (const subdir of subdirs) { |
| 176 | + const subdirPath = path.join(dirPath, subdir.name); |
| 177 | + |
| 178 | + // Look for case.yaml or case.yml |
| 179 | + let caseFilePath: string | undefined; |
| 180 | + for (const filename of ['case.yaml', 'case.yml']) { |
| 181 | + const candidate = path.join(subdirPath, filename); |
| 182 | + try { |
| 183 | + const s = await stat(candidate); |
| 184 | + if (s.isFile()) { |
| 185 | + caseFilePath = candidate; |
| 186 | + break; |
| 187 | + } |
| 188 | + } catch { |
| 189 | + // File doesn't exist, try next |
| 190 | + } |
| 191 | + } |
| 192 | + |
| 193 | + if (!caseFilePath) { |
| 194 | + console.warn( |
| 195 | + `${ANSI_YELLOW}Warning: Skipping directory '${subdir.name}' — no case.yaml found${ANSI_RESET}`, |
| 196 | + ); |
| 197 | + continue; |
| 198 | + } |
| 199 | + |
| 200 | + // Parse case.yaml as a single object (not array) |
| 201 | + let content: string; |
| 202 | + try { |
| 203 | + content = await readFile(caseFilePath, 'utf8'); |
| 204 | + } catch (error) { |
| 205 | + const message = error instanceof Error ? error.message : String(error); |
| 206 | + throw new Error(`Cannot read case file: ${caseFilePath}\n ${message}`); |
| 207 | + } |
| 208 | + |
| 209 | + const raw = parseYaml(content) as unknown; |
| 210 | + const parsed = interpolateEnv(raw, process.env); |
| 211 | + if (!isJsonObject(parsed)) { |
| 212 | + throw new Error( |
| 213 | + `Case file must contain a YAML object, got ${typeof parsed}: ${caseFilePath}`, |
| 214 | + ); |
| 215 | + } |
| 216 | + |
| 217 | + const caseObj = { ...parsed }; |
| 218 | + |
| 219 | + // Inject id from directory name if not specified |
| 220 | + if (caseObj.id === undefined || caseObj.id === null) { |
| 221 | + caseObj.id = subdir.name; |
| 222 | + } |
| 223 | + |
| 224 | + // Check for workspace/ subdirectory |
| 225 | + if (!caseObj.workspace) { |
| 226 | + const workspaceDirPath = path.join(subdirPath, 'workspace'); |
| 227 | + try { |
| 228 | + const s = await stat(workspaceDirPath); |
| 229 | + if (s.isDirectory()) { |
| 230 | + caseObj.workspace = { template: workspaceDirPath }; |
| 231 | + } |
| 232 | + } catch { |
| 233 | + // No workspace directory, that's fine |
| 234 | + } |
| 235 | + } |
| 236 | + |
| 237 | + results.push(caseObj); |
| 238 | + } |
| 239 | + |
| 240 | + return results; |
| 241 | +} |
| 242 | + |
161 | 243 | /** |
162 | 244 | * Process a tests array, expanding any file:// references into inline test objects. |
163 | 245 | * Returns a flat array of JsonValue where all file:// strings are replaced |
|
0 commit comments