1import * as GitHub from "@/GitHub/index.ts";
2import { fromToken } from "@/GitHub/Credentials.ts";
3import type { WikiPage, WikiPageProps } from "@/GitHub/WikiPage.ts";
4import { WikiPageProvider } from "@/GitHub/WikiPage.ts";
5import {
6 deleteWikiPage,
7 readWikiPage,
8 syncWikiPage,
9 wikiRepository,
10 type WikiRepository,
11} from "@/GitHub/WikiPage.ts";
12import * as Provider from "@/Provider.ts";
13import { exec } from "@/Util/exec.ts";
14import * as NodeServices from "@effect/platform-node/NodeServices";
15import { layer } from "alchemy-test";
16import * as Redacted from "effect/Redacted";
17import * as Result from "effect/Result";
18import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
19import * as Output from "@/Output.ts";
20import * as Test from "@/Test/Alchemy.ts";
21import { expect } from "alchemy-test";
22import * as Effect from "effect/Effect";
23import * as FileSystem from "effect/FileSystem";
24import * as Path from "effect/Path";
25import * as Stream from "effect/Stream";
26import * as ChildProcess from "effect/process/ChildProcess";
27
28const owner = process.env.GITHUB_TEST_OWNER ?? "alchemy-run-test";
29if (!["alchemy-run-test", "alchemy-run-test-2"].includes(owner)) {
30 throw new Error(
31 "GITHUB_TEST_OWNER must be alchemy-run-test or alchemy-run-test-2",
32 );
33}
34
35const { test } = Test.make({
36 providers: GitHub.providers({ baseUrl: "github.com" }),
37});
38
39// Repositories are retained because the testing token lacks delete_repo.
40const fixture = (suffix: string) =>
41 GitHub.Repository("Repo", {
42 owner,
43 name: `alchemy-pr-1578-wiki-${suffix}`,
44 visibility: "public",
45 hasWiki: true,
46 autoInit: true,
47 });
48
49const repoNameOf = (repo: GitHub.Repository) =>
50 Output.map(repo.fullName, (fullName) => fullName.split("/")[1]!);
51
52const git = Effect.fn(
53 function* (cwd: string, ...args: string[]) {
54 const handle = yield* ChildProcess.make("git", args, {
55 cwd,
56 env: { GIT_TERMINAL_PROMPT: "0" },
57 extendEnv: true,
58 });
59 const [exitCode, stdout, stderr] = yield* Effect.all(
60 [
61 handle.exitCode,
62 Stream.mkString(Stream.decodeText(handle.stdout)),
63 Stream.mkString(Stream.decodeText(handle.stderr)),
64 ],
65 { concurrency: 3 },
66 );
67 if (exitCode !== 0) {
68 return yield* Effect.fail(
69 new Error(`git ${args[0]} exited ${exitCode}: ${stderr}`),
70 );
71 }
72 return stdout;
73 },
74 Effect.scoped,
75 Effect.timeout("30 seconds"),
76);
77
78// Read the public wiki's Git repository independently of the resource provider.
79const getPage = (repository: string, title: string, extension = "md") =>
80 Effect.gen(function* () {
81 const fs = yield* FileSystem.FileSystem;
82 const path = yield* Path.Path;
83 const cwd = yield* Effect.sync(() => process.cwd());
84 const directory = yield* fs.makeTempDirectoryScoped({
85 directory: cwd,
86 prefix: ".wiki-test-",
87 });
88 yield* git(
89 directory,
90 "clone",
91 "--quiet",
92 `https://github.com/${owner}/${repository}.wiki.git`,
93 "wiki",
94 );
95 const wiki = path.join(directory, "wiki");
96 const file = `${title.replace(/\s+/g, "-")}.${extension}`;
97 if (!(yield* fs.exists(path.join(wiki, file)))) return undefined;
98 return {
99 content: yield* fs.readFileString(path.join(wiki, file)),
100 sha: (yield* git(wiki, "log", "-1", "--format=%H", "--", file)).trim(),
101 };
102 }).pipe(Effect.scoped);
103
104// TODO: GitHub requires the first wiki page to be saved in its web UI.
105// Repository hasWiki/autoInit cannot bootstrap the separate wiki Git repository.
106test.provider.todo(
107 "create and update wiki page",
108 (stack) =>
109 Effect.gen(function* () {
110 yield* stack.destroy();
111 const deploy = (content: string) =>
112 stack.deploy(
113 Effect.gen(function* () {
114 const repo = yield* fixture("lifecycle");
115 const page = yield* GitHub.WikiPage("Page", {
116 owner,
117 repository: repoNameOf(repo),
118 title: "Home",
119 content,
120 allowDelete: true,
121 });
122 return { repo, page };
123 }),
124 );
125
126 const created = yield* deploy("Welcome to the wiki!");
127 expect(created.page.title).toBe("Home");
128 expect(created.page.pageName).toBe("Home");
129 expect(created.page.htmlUrl).toBe(
130 `https://github.com/${owner}/alchemy-pr-1578-wiki-lifecycle/wiki/Home`,
131 );
132 expect(
133 (yield* getPage("alchemy-pr-1578-wiki-lifecycle", "Home"))?.content,
134 ).toBe("Welcome to the wiki!");
135
136 const updated = yield* deploy("# Updated Content\n\nThis is updated!");
137 expect(updated.page.sha).not.toBe(created.page.sha);
138 expect(
139 (yield* getPage("alchemy-pr-1578-wiki-lifecycle", "Home"))?.content,
140 ).toBe("# Updated Content\n\nThis is updated!");
141
142 yield* stack.destroy();
143 expect(
144 yield* getPage("alchemy-pr-1578-wiki-lifecycle", "Home"),
145 ).toBeUndefined();
146 }),
147 {
148 tags: [
149 "provider:github",
150 "provider:github:repository",
151 "provider:github:wikipage",
152 "live",
153 ],
154 timeout: 120_000,
155 },
156);
157
158test.provider.todo(
159 "create page with custom format",
160 (stack) =>
161 Effect.gen(function* () {
162 yield* stack.destroy();
163 const result = yield* stack.deploy(
164 Effect.gen(function* () {
165 const repo = yield* fixture("format");
166 return yield* GitHub.WikiPage("Page", {
167 owner,
168 repository: repoNameOf(repo),
169 title: "API Documentation",
170 content: `
171 = API Documentation
172
173 == Overview
174
175 The API provides...
176 `,
177 format: "asciidoc",
178 message: "Add API documentation",
179 allowDelete: true,
180 });
181 }),
182 );
183 expect(result.title).toBe("API Documentation");
184 expect(result.pageName).toBe("API-Documentation");
185 expect(
186 (yield* getPage(
187 "alchemy-pr-1578-wiki-format",
188 "API Documentation",
189 "asciidoc",
190 ))?.content,
191 ).toBe("= API Documentation\n\n== Overview\n\nThe API provides...");
192 yield* stack.destroy();
193 expect(
194 yield* getPage(
195 "alchemy-pr-1578-wiki-format",
196 "API Documentation",
197 "asciidoc",
198 ),
199 ).toBeUndefined();
200 }),
201 {
202 tags: [
203 "provider:github",
204 "provider:github:repository",
205 "provider:github:wikipage",
206 "live",
207 ],
208 timeout: 120_000,
209 },
210);
211
212test.provider.todo(
213 "preserve page when allowDelete is false (default)",
214 (stack) =>
215 Effect.gen(function* () {
216 yield* stack.destroy();
217 const deploy = (allowDelete?: boolean) =>
218 stack.deploy(
219 Effect.gen(function* () {
220 const repo = yield* fixture("preserve");
221 return yield* GitHub.WikiPage("Page", {
222 owner,
223 repository: repoNameOf(repo),
224 title: "Documentation",
225 content: "Important documentation that should be preserved",
226 allowDelete,
227 });
228 }),
229 );
230 const created = yield* deploy();
231 yield* stack.destroy();
232 const retained = yield* getPage(
233 "alchemy-pr-1578-wiki-preserve",
234 "Documentation",
235 );
236 expect(retained?.sha).toBe(created.sha);
237 expect(retained?.content).toBe(
238 "Important documentation that should be preserved",
239 );
240
241 // Re-manage the retained page with deletion enabled to verify cleanup.
242 yield* deploy(true);
243 yield* stack.destroy();
244 expect(
245 yield* getPage("alchemy-pr-1578-wiki-preserve", "Documentation"),
246 ).toBeUndefined();
247 }),
248 {
249 tags: [
250 "provider:github",
251 "provider:github:repository",
252 "provider:github:wikipage",
253 "live",
254 ],
255 timeout: 120_000,
256 },
257);
258
259test.provider.todo(
260 "replace page when title changes",
261 (stack) =>
262 Effect.gen(function* () {
263 yield* stack.destroy();
264 const deploy = (title: string, content: string) =>
265 stack.deploy(
266 Effect.gen(function* () {
267 const repo = yield* fixture("replace");
268 return yield* GitHub.WikiPage("Page", {
269 owner,
270 repository: repoNameOf(repo),
271 title,
272 content,
273 allowDelete: true,
274 });
275 }),
276 );
277 const created = yield* deploy("Getting Started", "Original guide");
278 expect(created.title).toBe("Getting Started");
279 const replaced = yield* deploy(
280 "Quick Start",
281 "New guide with different title",
282 );
283 expect(replaced.title).toBe("Quick Start");
284 expect(replaced.pageName).toBe("Quick-Start");
285 expect(
286 yield* getPage("alchemy-pr-1578-wiki-replace", "Getting Started"),
287 ).toBeUndefined();
288 expect(
289 (yield* getPage("alchemy-pr-1578-wiki-replace", "Quick Start"))
290 ?.content,
291 ).toBe("New guide with different title");
292 yield* stack.destroy();
293 expect(
294 yield* getPage("alchemy-pr-1578-wiki-replace", "Quick Start"),
295 ).toBeUndefined();
296 }),
297 {
298 tags: [
299 "provider:github",
300 "provider:github:repository",
301 "provider:github:wikipage",
302 "live",
303 ],
304 timeout: 120_000,
305 },
306);
307
308// Deterministic Git-transport tests over local bare-repository fixtures.
309const props: WikiPageProps = {
310 owner: "alchemy-run-test",
311 repository: "alchemy-pr-1578-wiki-unit",
312 title: "Getting Started",
313 content: "First revision",
314 allowDelete: true,
315};
316const token = "wiki-fixture-secret";
317
318const fixtureGit = Effect.fn(
319 function* (cwd: string, ...args: string[]) {
320 const env = yield* Effect.sync(() => ({
321 PATH: process.env.PATH,
322 HOME: cwd,
323 GIT_CONFIG_NOSYSTEM: "1",
324 GIT_CONFIG_GLOBAL: "/dev/null",
325 GIT_TERMINAL_PROMPT: "0",
326 GIT_AUTHOR_NAME: "Wiki fixture",
327 GIT_AUTHOR_EMAIL: "wiki-fixture@example.com",
328 GIT_COMMITTER_NAME: "Wiki fixture",
329 GIT_COMMITTER_EMAIL: "wiki-fixture@example.com",
330 }));
331 const result = yield* exec(
332 ChildProcess.make("git", args, { cwd, env, extendEnv: false }),
333 );
334 expect(result.exitCode).toBe(0);
335 return result.stdout;
336 },
337 Effect.scoped,
338 Effect.timeout("20 seconds"),
339);
340
341const gitFixture = Effect.gen(function* () {
342 const fs = yield* FileSystem.FileSystem;
343 const path = yield* Path.Path;
344 const directory = yield* fs.makeTempDirectoryScoped({
345 prefix: "alchemy-wiki-fixture-",
346 });
347 const remote = path.join(directory, "fixture.wiki.git");
348 const seed = path.join(directory, "seed");
349 yield* fixtureGit(
350 directory,
351 "init",
352 "--bare",
353 "--initial-branch=main",
354 remote,
355 );
356 yield* fixtureGit(directory, "clone", remote, seed);
357 yield* fs.writeFileString(
358 path.join(seed, "Bootstrap.md"),
359 "Unmanaged bootstrap page",
360 );
361 yield* fixtureGit(seed, "add", "Bootstrap.md");
362 yield* fixtureGit(seed, "commit", "-m", "Bootstrap wiki");
363 yield* fixtureGit(seed, "push", "origin", "HEAD");
364 const repository: WikiRepository = {
365 remote,
366 htmlUrl: `https://github.com/${props.owner}/${props.repository}/wiki`,
367 token: Redacted.make(token),
368 };
369 return { fs, path, directory, seed, repository };
370});
371
372const describe = layer(NodeServices.layer, { excludeTestServices: true });
373
374describe("WikiPage Git fixtures", (it) => {
375 it.effect(
376 "runs provider create, recovery, adoption, update, replacement, and delete against Git",
377 () =>
378 Effect.gen(function* () {
379 const { repository } = yield* gitFixture;
380 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
381 const remote = `https://github.com/${props.owner}/${props.repository}.wiki.git`;
382 const localTransport = ChildProcessSpawner.make((command) =>
383 spawner.spawn(
384 command._tag === "StandardCommand"
385 ? ChildProcess.make(
386 command.command,
387 command.args.map((arg) =>
388 arg === remote ? repository.remote : arg,
389 ),
390 command.options,
391 )
392 : command,
393 ),
394 );
395 yield* Effect.gen(function* () {
396 const provider =
397 yield* Provider.Provider<WikiPage>("GitHub.WikiPage");
398 const context = {
399 id: "Page",
400 fqn: "Page",
401 instanceId: "fixture",
402 bindings: [],
403 session: {
404 emit: () => Effect.void,
405 done: () => Effect.void,
406 note: () => Effect.void,
407 },
408 };
409 const created = yield* provider.reconcile({
410 ...context,
411 news: props,
412 olds: undefined,
413 output: undefined,
414 });
415 expect(
416 yield* provider.read!({
417 ...context,
418 olds: props,
419 output: undefined,
420 }),
421 ).toEqual(created);
422 const adopted = yield* provider.reconcile({
423 ...context,
424 news: props,
425 olds: undefined,
426 output: created,
427 });
428 expect(adopted).toEqual(created);
429 const news = { ...props, content: "Changed via provider" };
430 const updated = yield* provider.reconcile({
431 ...context,
432 news,
433 olds: props,
434 output: adopted,
435 });
436 expect(updated.sha).not.toBe(created.sha);
437 yield* provider.delete({
438 ...context,
439 olds: { ...news, allowDelete: undefined },
440 output: updated,
441 });
442 expect(
443 yield* provider.read!({
444 ...context,
445 olds: news,
446 output: undefined,
447 }),
448 ).toEqual(updated);
449 const renamed = { ...news, title: "Quick Start" };
450 const replacement = yield* provider.reconcile({
451 ...context,
452 news: renamed,
453 olds: undefined,
454 output: undefined,
455 });
456 yield* provider.delete({ ...context, olds: news, output: updated });
457 expect(
458 yield* provider.read!({
459 ...context,
460 olds: news,
461 output: undefined,
462 }),
463 ).toBeUndefined();
464 expect(
465 yield* provider.read!({
466 ...context,
467 olds: renamed,
468 output: undefined,
469 }),
470 ).toEqual(replacement);
471 yield* provider.delete({
472 ...context,
473 olds: renamed,
474 output: replacement,
475 });
476 yield* provider.delete({
477 ...context,
478 olds: renamed,
479 output: replacement,
480 });
481 }).pipe(
482 Effect.provide(WikiPageProvider()),
483 Effect.provide(fromToken(token)),
484 Effect.provideService(
485 ChildProcessSpawner.ChildProcessSpawner,
486 localTransport,
487 ),
488 );
489 }).pipe(Effect.scoped),
490 { tags: ["provider:github", "provider:github:wikipage", "local"] },
491 );
492 it.effect(
493 "creates, recovers, updates, avoids no-op commits, and deletes idempotently",
494 () =>
495 Effect.gen(function* () {
496 const { directory, repository } = yield* gitFixture;
497 expect(yield* readWikiPage(repository, props)).toBeUndefined();
498 const created = yield* syncWikiPage(repository, props);
499 expect(created.pageName).toBe("Getting-Started");
500 expect(created.htmlUrl).toBe(`${repository.htmlUrl}/Getting-Started`);
501 expect(yield* readWikiPage(repository, props)).toEqual(created);
502 expect(yield* syncWikiPage(repository, props)).toEqual(created);
503 const updated = yield* syncWikiPage(repository, {
504 ...props,
505 content: "\n Updated\n indented\n",
506 message: "Update fixture page",
507 });
508 expect(updated.sha).not.toBe(created.sha);
509 expect(
510 (yield* fixtureGit(
511 directory,
512 "--git-dir",
513 repository.remote,
514 "log",
515 "-1",
516 "--format=%s",
517 )).trim(),
518 ).toBe("Update fixture page");
519 expect(
520 yield* fixtureGit(
521 directory,
522 "--git-dir",
523 repository.remote,
524 "show",
525 "HEAD:Getting-Started.md",
526 ),
527 ).toBe("Updated\n indented");
528 yield* deleteWikiPage(repository, props);
529 yield* deleteWikiPage(repository, props);
530 expect(yield* readWikiPage(repository, props)).toBeUndefined();
531 expect(
532 yield* fixtureGit(
533 directory,
534 "--git-dir",
535 repository.remote,
536 "show",
537 "HEAD:Bootstrap.md",
538 ),
539 ).toBe("Unmanaged bootstrap page");
540 expect(
541 (yield* fixtureGit(
542 directory,
543 "--git-dir",
544 repository.remote,
545 "rev-list",
546 "--count",
547 "HEAD",
548 )).trim(),
549 ).toBe("4");
550 }).pipe(Effect.scoped),
551 { tags: ["provider:github", "provider:github:wikipage", "local"] },
552 );
553
554 it.effect(
555 "preserves by default and recreates an externally deleted page",
556 () =>
557 Effect.gen(function* () {
558 const { repository } = yield* gitFixture;
559 const created = yield* syncWikiPage(repository, props);
560 yield* deleteWikiPage(repository, { ...props, allowDelete: undefined });
561 expect(yield* readWikiPage(repository, props)).toEqual(created);
562 yield* deleteWikiPage(repository, props);
563 const recreated = yield* syncWikiPage(repository, props);
564 expect(recreated.sha).not.toBe(created.sha);
565 expect(yield* readWikiPage(repository, props)).toEqual(recreated);
566 yield* deleteWikiPage(repository, props);
567 }).pipe(Effect.scoped),
568 { tags: ["provider:github", "provider:github:wikipage", "local"] },
569 );
570
571 it.effect(
572 "converges all formats, removes previous extensions, and preserves unrelated pages",
573 () =>
574 Effect.gen(function* () {
575 const { directory, repository } = yield* gitFixture;
576 const formats = {
577 markdown: "md",
578 asciidoc: "asciidoc",
579 mediawiki: "mediawiki",
580 org: "org",
581 pod: "pod",
582 rdoc: "rdoc",
583 rest: "rst",
584 textile: "textile",
585 } as const;
586 for (const format of Object.keys(formats) as Array<
587 keyof typeof formats
588 >) {
589 const next = { ...props, format };
590 const page = yield* syncWikiPage(repository, next);
591 expect(yield* readWikiPage(repository, props)).toEqual(page);
592 expect(
593 (yield* fixtureGit(
594 directory,
595 "--git-dir",
596 repository.remote,
597 "ls-tree",
598 "--name-only",
599 "HEAD",
600 ))
601 .trim()
602 .split("\n"),
603 ).toEqual(["Bootstrap.md", `Getting-Started.${formats[format]}`]);
604 }
605 yield* deleteWikiPage(repository, props);
606 expect(yield* readWikiPage(repository, props)).toBeUndefined();
607 }).pipe(Effect.scoped),
608 { tags: ["provider:github", "provider:github:wikipage", "local"] },
609 );
610
611 it.effect(
612 "recovers alternate extensions and safely replaces symlink pages",
613 () =>
614 Effect.gen(function* () {
615 const { fs, path, directory, seed, repository } = yield* gitFixture;
616 yield* fs.writeFileString(
617 path.join(seed, "Getting-Started.markdown"),
618 "Existing page",
619 );
620 const outside = path.join(directory, "outside");
621 yield* fs.writeFileString(outside, "Do not overwrite");
622 yield* fs.symlink(outside, path.join(seed, "Linked.md"));
623 yield* fixtureGit(seed, "add", ".");
624 yield* fixtureGit(seed, "commit", "-m", "External pages");
625 yield* fixtureGit(seed, "push", "origin", "HEAD");
626 expect(yield* readWikiPage(repository, props)).toBeDefined();
627 yield* syncWikiPage(repository, props);
628 yield* syncWikiPage(repository, { ...props, title: "Linked" });
629 expect(yield* fs.readFileString(outside)).toBe("Do not overwrite");
630 expect(
631 (yield* fixtureGit(
632 directory,
633 "--git-dir",
634 repository.remote,
635 "ls-tree",
636 "HEAD",
637 "Linked.md",
638 )).startsWith("100644"),
639 ).toBe(true);
640 expect(
641 (yield* fixtureGit(
642 directory,
643 "--git-dir",
644 repository.remote,
645 "ls-tree",
646 "--name-only",
647 "HEAD",
648 )).includes("Getting-Started.markdown"),
649 ).toBe(false);
650 }).pipe(Effect.scoped),
651 { tags: ["provider:github", "provider:github:wikipage", "local"] },
652 );
653
654 it.effect(
655 "retries concurrent pushes without losing sibling pages",
656 () =>
657 Effect.gen(function* () {
658 const { repository } = yield* gitFixture;
659 const titles = ["First", "Second", "Third"];
660 yield* Effect.all(
661 titles.map((title) => syncWikiPage(repository, { ...props, title })),
662 { concurrency: 3 },
663 );
664 for (const title of titles)
665 expect(
666 yield* readWikiPage(repository, { ...props, title }),
667 ).toBeDefined();
668 }).pipe(Effect.scoped),
669 { tags: ["provider:github", "provider:github:wikipage", "local"] },
670 );
671
672 it.effect(
673 "reports unavailable repositories with actionable errors without skipping lifecycle work",
674 () =>
675 Effect.gen(function* () {
676 const { directory, path, repository } = yield* gitFixture;
677 const missing = {
678 ...repository,
679 remote: path.join(directory, "uninitialized.wiki.git"),
680 };
681 const result = yield* Effect.result(syncWikiPage(missing, props));
682 expect(Result.isFailure(result)).toBe(true);
683 if (Result.isFailure(result)) {
684 expect(result.failure._tag).toBe("WikiRepositoryUnavailable");
685 expect(result.failure.message).toContain(
686 "first page in the GitHub web UI",
687 );
688 expect(result.failure.message).not.toContain(token);
689 }
690 expect(yield* readWikiPage(missing, props)).toBeUndefined();
691 yield* deleteWikiPage(missing, props);
692 }).pipe(Effect.scoped),
693 { tags: ["provider:github", "provider:github:wikipage", "local"] },
694 );
695
696 it.effect(
697 "keeps credentials out of arguments and config and removes temporary checkouts",
698 () =>
699 Effect.gen(function* () {
700 const { fs, repository } = yield* gitFixture;
701 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
702 const directories = new Set<string>();
703 const safeSpawner = ChildProcessSpawner.make((command) =>
704 Effect.gen(function* () {
705 if (command._tag === "StandardCommand") {
706 expect(command.args.join(" ")).not.toContain(token);
707 expect(command.options.extendEnv).toBe(false);
708 const env = command.options.env!;
709 expect(env.GIT_CONFIG_KEY_0).toBe(
710 `http.${repository.remote}.extraHeader`,
711 );
712 expect(env.GIT_CONFIG_VALUE_0).toMatch(/^Authorization: Basic /);
713 expect(env.GIT_TRACE).toBeUndefined();
714 directories.add(env.HOME!);
715 expect(yield* fs.readFileString(env.GIT_CONFIG_GLOBAL!)).toBe("");
716 }
717 return yield* spawner.spawn(command);
718 }),
719 );
720 yield* syncWikiPage(repository, props).pipe(
721 Effect.provideService(
722 ChildProcessSpawner.ChildProcessSpawner,
723 safeSpawner,
724 ),
725 );
726 expect(directories.size).toBe(1);
727 for (const directory of directories)
728 expect(yield* fs.exists(directory)).toBe(false);
729 }).pipe(Effect.scoped),
730 { tags: ["provider:github", "provider:github:wikipage", "local"] },
731 );
732
733 it.effect(
734 "rejects unsafe titles and produces encoded browser URLs",
735 () =>
736 Effect.gen(function* () {
737 const { repository } = yield* gitFixture;
738 for (const title of [
739 "../escape",
740 "a/b",
741 "a\\b",
742 "",
743 "..",
744 " line",
745 "line\nbreak",
746 ]) {
747 const result = yield* Effect.result(
748 syncWikiPage(repository, { ...props, title }),
749 );
750 expect(Result.isFailure(result)).toBe(true);
751 if (Result.isFailure(result))
752 expect(result.failure._tag).toBe("InvalidWikiPage");
753 }
754 const page = yield* syncWikiPage(repository, {
755 ...props,
756 title: "API #1?",
757 });
758 expect(page.htmlUrl).toBe(`${repository.htmlUrl}/API-%231%3F`);
759 }).pipe(Effect.scoped),
760 { tags: ["unit", "provider:github", "provider:github:wikipage", "local"] },
761 );
762
763 it.effect(
764 "maps public, enterprise, and data-residency API hosts to Git hosts",
765 () =>
766 Effect.gen(function* () {
767 const cases = [
768 [undefined, "https://enterprise.example.com"],
769 ["github.com", "https://github.com"],
770 [
771 "https://wiki.example.com:8443/api/v3",
772 "https://wiki.example.com:8443",
773 ],
774 ["https://api.acme.ghe.com", "https://acme.ghe.com"],
775 ] as const;
776 for (const [baseUrl, origin] of cases) {
777 const repository = yield* wikiRepository({ ...props, baseUrl });
778 expect(repository.remote).toBe(
779 `${origin}/${props.owner}/${props.repository}.wiki.git`,
780 );
781 expect(repository.htmlUrl).toBe(
782 `${origin}/${props.owner}/${props.repository}/wiki`,
783 );
784 }
785 const insecure = yield* Effect.result(
786 wikiRepository({ ...props, baseUrl: "http://wiki.example.com" }),
787 );
788 expect(Result.isFailure(insecure)).toBe(true);
789 }).pipe(
790 Effect.provide(fromToken(token, { baseUrl: "enterprise.example.com" })),
791 ),
792 { tags: ["unit", "provider:github", "provider:github:wikipage", "local"] },
793 );
794
795 it.effect(
796 "replaces title, repository, owner, and host changes but updates formats in place",
797 () =>
798 Effect.gen(function* () {
799 const provider = yield* Provider.Provider<WikiPage>("GitHub.WikiPage");
800 const diff = (news: WikiPageProps) =>
801 provider.diff!({
802 id: "Page",
803 fqn: "Page",
804 instanceId: "fixture",
805 olds: props,
806 news,
807 oldBindings: [],
808 newBindings: [],
809 output: undefined,
810 });
811 for (const news of [
812 { ...props, title: "Renamed" },
813 { ...props, repository: "other" },
814 { ...props, owner: "alchemy-run-test-2" },
815 { ...props, baseUrl: "enterprise.example.com" },
816 ])
817 expect(yield* diff(news)).toEqual({ action: "replace" });
818 expect(yield* diff({ ...props, format: "asciidoc" })).toBeUndefined();
819 expect(
820 yield* diff({ ...props, title: "Getting-Started" }),
821 ).toBeUndefined();
822 expect(
823 yield* diff({ ...props, baseUrl: "https://api.github.com" }),
824 ).toBeUndefined();
825 }).pipe(
826 Effect.provide(WikiPageProvider()),
827 Effect.provide(fromToken(token)),
828 ),
829 { tags: ["unit", "provider:github", "provider:github:wikipage", "local"] },
830 );
831});