pending.ts 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. /**
  2. * Helpers for RED-FIRST tests.
  3. *
  4. * Subtask 03 writes the tests that define "done" for subtasks 04-11. Those subtasks have
  5. * not landed, so the modules and the `content/` tree under test do not exist yet. A bare
  6. * top-level `import` of a missing module aborts collection for the WHOLE file, which turns
  7. * one honest red test into a wall of unrelated errors that say nothing about what is
  8. * missing.
  9. *
  10. * So: probe the filesystem first, import dynamically only when the target is really there,
  11. * and otherwise fail with a sentence naming the subtask that owes the artifact. A red test
  12. * here is a specification, not a crash.
  13. */
  14. import { existsSync, readdirSync, statSync } from "node:fs";
  15. import { dirname, join, resolve } from "node:path";
  16. import { fileURLToPath, pathToFileURL } from "node:url";
  17. import { expect } from "vitest";
  18. const HERE = dirname(fileURLToPath(import.meta.url));
  19. /** `packages/compatibility-layer` */
  20. export const PACKAGE_ROOT = resolve(HERE, "../..");
  21. /** The repository root — four levels up from `tests/support/`. */
  22. export const REPO_ROOT = resolve(HERE, "../../../..");
  23. /** Absolute path to a repo-relative path. */
  24. export function repoPath(...segments: string[]): string {
  25. return join(REPO_ROOT, ...segments);
  26. }
  27. /** Absolute path to a package-relative path. */
  28. export function packagePath(...segments: string[]): string {
  29. return join(PACKAGE_ROOT, ...segments);
  30. }
  31. /** The marker every RED-by-design failure carries, so they are greppable in CI output. */
  32. const MARKER = "RED-BY-DESIGN";
  33. /**
  34. * Fail with a message that states precisely what is missing and who owes it.
  35. *
  36. * @param what the artifact that does not exist yet
  37. * @param owedBy the subtask expected to deliver it (e.g. "subtask 05")
  38. * @param why what this test would assert once it exists
  39. */
  40. export function pending(what: string, owedBy: string, why: string): never {
  41. expect.fail(
  42. `${MARKER} — ${what} does not exist yet.\n` +
  43. ` Owed by: ${owedBy}\n` +
  44. ` Once it lands, this test asserts: ${why}\n` +
  45. ` This failure is the specification, not a bug in the test.`
  46. );
  47. }
  48. /**
  49. * Dynamically import a package-relative module, failing cleanly when it is not on disk.
  50. *
  51. * The existence probe comes first on purpose: it means we never hand a missing specifier to
  52. * the loader, so the failure is our sentence rather than a resolver stack trace.
  53. *
  54. * @param relativePath package-relative module path, e.g. `"src/core/ReferenceResolver.ts"`
  55. * @param owedBy the subtask expected to deliver it
  56. * @param why what this test would assert once it exists
  57. */
  58. export async function importPending<T = Record<string, unknown>>(
  59. relativePath: string,
  60. owedBy: string,
  61. why: string
  62. ): Promise<T> {
  63. const absolute = packagePath(relativePath);
  64. if (!existsSync(absolute)) {
  65. pending(`module ${relativePath}`, owedBy, why);
  66. }
  67. try {
  68. return (await import(/* @vite-ignore */ pathToFileURL(absolute).href)) as T;
  69. } catch (cause) {
  70. expect.fail(
  71. `${MARKER} — module ${relativePath} exists but failed to import.\n` +
  72. ` Owed by: ${owedBy}\n` +
  73. ` Once it imports, this test asserts: ${why}\n` +
  74. ` Import error: ${cause instanceof Error ? cause.message : String(cause)}`
  75. );
  76. }
  77. }
  78. /**
  79. * Pull named exports out of a pending module, failing cleanly on a missing symbol.
  80. *
  81. * A module can land before its full surface does; `undefined is not a constructor` is not a
  82. * diagnostic, so name the symbol and the subtask instead.
  83. */
  84. export async function importPendingSymbols<T extends Record<string, unknown>>(
  85. relativePath: string,
  86. symbols: readonly string[],
  87. owedBy: string,
  88. why: string
  89. ): Promise<T> {
  90. const module = await importPending<Record<string, unknown>>(relativePath, owedBy, why);
  91. const missing = symbols.filter((symbol) => module[symbol] === undefined);
  92. if (missing.length > 0) {
  93. expect.fail(
  94. `${MARKER} — module ${relativePath} does not export: ${missing.join(", ")}.\n` +
  95. ` Owed by: ${owedBy}\n` +
  96. ` Exports found: ${Object.keys(module).join(", ") || "(none)"}\n` +
  97. ` Once exported, this test asserts: ${why}`
  98. );
  99. }
  100. return module as T;
  101. }
  102. /**
  103. * Require a method on an already-constructed instance, failing cleanly when it is absent.
  104. *
  105. * The module-and-symbol probes above are not enough on their own: `ClaudeAdapter.ts` already
  106. * exists and exports `ClaudeAdapter`, so both probes pass — and then the test dies on
  107. * `adapter.fromCanonical is not a function`, a TypeError that names neither the missing
  108. * capability nor the subtask that owes it. The old class speaks `fromOAC`; the canonical
  109. * build needs `fromCanonical`. That gap is a specification, so it gets a sentence.
  110. */
  111. export function requireMethod<T extends object>(
  112. instance: T,
  113. method: string,
  114. owedBy: string,
  115. why: string
  116. ): T {
  117. if (typeof (instance as Record<string, unknown>)[method] !== "function") {
  118. const surface = [
  119. ...Object.getOwnPropertyNames(Object.getPrototypeOf(instance) as object),
  120. ...Object.keys(instance),
  121. ]
  122. .filter((name) => name !== "constructor")
  123. .sort();
  124. expect.fail(
  125. `${MARKER} — ${instance.constructor.name} has no ${method}() method.\n` +
  126. ` Owed by: ${owedBy}\n` +
  127. ` Methods found: ${surface.join(", ") || "(none)"}\n` +
  128. ` Once it exists, this test asserts: ${why}`
  129. );
  130. }
  131. return instance;
  132. }
  133. /**
  134. * Require a repo-relative directory, failing cleanly when absent.
  135. *
  136. * `content/` is built by subtask 09 in parallel with this one; an ENOENT stack trace from
  137. * `readdirSync` would say nothing useful about that.
  138. */
  139. export function requireDir(relativePath: string, owedBy: string, why: string): string {
  140. const absolute = repoPath(relativePath);
  141. if (!existsSync(absolute) || !statSync(absolute).isDirectory()) {
  142. pending(`directory ${relativePath}/`, owedBy, why);
  143. }
  144. return absolute;
  145. }
  146. /** Recursively list files under `absoluteDir` matching `extension`, sorted for determinism. */
  147. export function listFiles(absoluteDir: string, extension = ".md"): string[] {
  148. const walk = (dir: string): string[] =>
  149. readdirSync(dir, { withFileTypes: true })
  150. .flatMap((entry) => {
  151. const full = join(dir, entry.name);
  152. if (entry.isDirectory()) return walk(full);
  153. return entry.isFile() && entry.name.endsWith(extension) ? [full] : [];
  154. })
  155. .sort();
  156. return walk(absoluteDir).sort();
  157. }