|
| 1 | +/** |
| 2 | + * End-to-end integration test for issue #204 — REAL git, REAL gh-pages, REAL filesystem. |
| 3 | + * |
| 4 | + * No mocks. This test proves the `beforeAdd` cleanup hook actually produces a clean |
| 5 | + * gh-pages commit when the branch previously contained dotfiles and submodule gitlinks. |
| 6 | + * |
| 7 | + * Flow: |
| 8 | + * 1. Create a local bare git repo (serves as "remote"). |
| 9 | + * 2. Seed a gh-pages branch containing: |
| 10 | + * - .github/workflows/deploy.yml (dot-directory, nested) |
| 11 | + * - .gitignore (dotfile) |
| 12 | + * - .gitmodules (dotfile) |
| 13 | + * - a submodule gitlink `build` (mode 160000) |
| 14 | + * - stale.html (regular file gh-pages' own remove would catch) |
| 15 | + * 3. Run `engine.run()` against the bare repo with a dist containing only index.html. |
| 16 | + * 4. `git ls-tree -r gh-pages` on the bare repo → assert ONLY `index.html` landed. |
| 17 | + * |
| 18 | + * A companion test demonstrates the upstream bug itself by calling `gh-pages.publish()` |
| 19 | + * directly (bypassing our hook) and observing the leftovers leak into the commit. That |
| 20 | + * test will start failing once tschaub/gh-pages ships PR #612 in a release — which is |
| 21 | + * fine; it's the signal that our workaround can be removed. |
| 22 | + */ |
| 23 | + |
| 24 | +import * as path from 'path'; |
| 25 | +import * as fs from 'fs/promises'; |
| 26 | +import * as os from 'os'; |
| 27 | +import { execSync } from 'child_process'; |
| 28 | + |
| 29 | +import { logging } from '@angular-devkit/core'; |
| 30 | + |
| 31 | +import * as engine from './engine'; |
| 32 | +import { cleanupMonkeypatch } from './engine.prepare-options-helpers'; |
| 33 | + |
| 34 | +// NO MOCKS — we want real gh-pages behavior end-to-end |
| 35 | +const ghPages = require('gh-pages'); |
| 36 | + |
| 37 | +interface GitOptions { |
| 38 | + readonly cwd: string; |
| 39 | +} |
| 40 | + |
| 41 | +function git(args: string, options: GitOptions): string { |
| 42 | + return execSync(`git -C "${options.cwd}" ${args}`, { stdio: ['pipe', 'pipe', 'pipe'] }) |
| 43 | + .toString() |
| 44 | + .trim(); |
| 45 | +} |
| 46 | + |
| 47 | +async function seedGhPagesBranch(workDir: string, bareRepoPath: string): Promise<void> { |
| 48 | + await fs.mkdir(workDir, { recursive: true }); |
| 49 | + execSync(`git init "${workDir}"`, { stdio: 'pipe' }); |
| 50 | + git('config user.email "seed@test.com"', { cwd: workDir }); |
| 51 | + git('config user.name "Seed"', { cwd: workDir }); |
| 52 | + git('checkout -b gh-pages', { cwd: workDir }); |
| 53 | + |
| 54 | + // Dotfiles and dot-directory (what gh-pages' broken remove step misses) |
| 55 | + await fs.mkdir(path.join(workDir, '.github', 'workflows'), { recursive: true }); |
| 56 | + await fs.writeFile(path.join(workDir, '.github', 'workflows', 'deploy.yml'), 'name: deploy\n'); |
| 57 | + await fs.writeFile(path.join(workDir, '.gitignore'), 'node_modules\n'); |
| 58 | + await fs.writeFile( |
| 59 | + path.join(workDir, '.gitmodules'), |
| 60 | + '[submodule "build"]\n\tpath = build\n\turl = https://example.invalid/build.git\n' |
| 61 | + ); |
| 62 | + // A regular non-dot file — gh-pages' own remove step WILL catch this one. |
| 63 | + await fs.writeFile(path.join(workDir, 'stale.html'), '<html>stale</html>\n'); |
| 64 | + |
| 65 | + git('add .github .gitignore .gitmodules stale.html', { cwd: workDir }); |
| 66 | + git('commit -m "seed dotfiles and stale content"', { cwd: workDir }); |
| 67 | + |
| 68 | + // Add a submodule gitlink without needing a real subrepo. |
| 69 | + // update-index --cacheinfo creates the 160000 entry directly in the index. |
| 70 | + const seedSha = git('rev-parse HEAD', { cwd: workDir }); |
| 71 | + git(`update-index --add --cacheinfo 160000,${seedSha},build`, { cwd: workDir }); |
| 72 | + git('commit -m "add submodule gitlink"', { cwd: workDir }); |
| 73 | + |
| 74 | + git(`remote add origin "${bareRepoPath}"`, { cwd: workDir }); |
| 75 | + git('push origin gh-pages', { cwd: workDir }); |
| 76 | +} |
| 77 | + |
| 78 | +describe('end-to-end cleanup regression (issue #204, real git)', () => { |
| 79 | + let tempDir: string; |
| 80 | + let distDir: string; |
| 81 | + let bareRepoPath: string; |
| 82 | + let originalEnv: NodeJS.ProcessEnv; |
| 83 | + |
| 84 | + // Unique suffix so parallel-ish reruns never collide in /tmp. |
| 85 | + const testRunId = `${Date.now()}-${Math.random().toString(36).substring(7)}`; |
| 86 | + |
| 87 | + beforeEach(async () => { |
| 88 | + cleanupMonkeypatch(); |
| 89 | + |
| 90 | + // Isolate from ambient CI envs and tokens so engine.prepareOptions doesn't |
| 91 | + // rewrite the repo URL or append CI metadata during tests. |
| 92 | + originalEnv = { ...process.env }; |
| 93 | + delete process.env.TRAVIS; |
| 94 | + delete process.env.CIRCLECI; |
| 95 | + delete process.env.GITHUB_ACTIONS; |
| 96 | + delete process.env.GH_TOKEN; |
| 97 | + delete process.env.PERSONAL_TOKEN; |
| 98 | + delete process.env.GITHUB_TOKEN; |
| 99 | + |
| 100 | + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), `ghp-204-${testRunId}-`)); |
| 101 | + |
| 102 | + distDir = path.join(tempDir, 'dist'); |
| 103 | + await fs.mkdir(distDir, { recursive: true }); |
| 104 | + await fs.writeFile(path.join(distDir, 'index.html'), '<html>fresh dist</html>\n'); |
| 105 | + |
| 106 | + bareRepoPath = path.join(tempDir, 'bare.git'); |
| 107 | + execSync(`git init --bare "${bareRepoPath}"`, { stdio: 'pipe' }); |
| 108 | + |
| 109 | + await seedGhPagesBranch(path.join(tempDir, 'seed'), bareRepoPath); |
| 110 | + |
| 111 | + // Clear gh-pages' cache so each test starts from a clean clone. |
| 112 | + ghPages.clean(); |
| 113 | + }); |
| 114 | + |
| 115 | + afterEach(async () => { |
| 116 | + ghPages.clean(); |
| 117 | + cleanupMonkeypatch(); |
| 118 | + process.env = originalEnv; |
| 119 | + await fs.rm(tempDir, { recursive: true, force: true }); |
| 120 | + }); |
| 121 | + |
| 122 | + it('engine.run() produces a gh-pages commit containing ONLY dist files (no dotfiles, no submodule)', async () => { |
| 123 | + await engine.run( |
| 124 | + distDir, |
| 125 | + { |
| 126 | + repo: bareRepoPath, |
| 127 | + branch: 'gh-pages', |
| 128 | + dotfiles: true, |
| 129 | + // Keep assertions simple: don't let our own 404.html / .nojekyll machinery |
| 130 | + // add files to the expected set. |
| 131 | + notfound: false, |
| 132 | + nojekyll: false, |
| 133 | + name: 'Test', |
| 134 | + email: 'test@test.com', |
| 135 | + message: 'test deploy' |
| 136 | + }, |
| 137 | + new logging.NullLogger() |
| 138 | + ); |
| 139 | + |
| 140 | + // Inspect what actually landed on gh-pages in the bare repo. |
| 141 | + const tree = git('ls-tree -r gh-pages --name-only', { cwd: bareRepoPath }) |
| 142 | + .split('\n') |
| 143 | + .filter(Boolean) |
| 144 | + .sort(); |
| 145 | + |
| 146 | + expect(tree).toEqual(['index.html']); |
| 147 | + |
| 148 | + // Explicit negative checks — makes regressions very readable on failure. |
| 149 | + expect(tree).not.toContain('.gitignore'); |
| 150 | + expect(tree).not.toContain('.gitmodules'); |
| 151 | + expect(tree).not.toContain('.github/workflows/deploy.yml'); |
| 152 | + expect(tree).not.toContain('build'); |
| 153 | + expect(tree).not.toContain('stale.html'); |
| 154 | + }, 30_000); |
| 155 | + |
| 156 | + it('baseline: without our hook, gh-pages alone leaks dotfiles and submodule gitlinks (demonstrates the upstream bug)', async () => { |
| 157 | + // Call gh-pages.publish() directly — no engine.run(), no beforeAdd hook. |
| 158 | + // This is exactly what angular-cli-ghpages v3 did before our fix. |
| 159 | + await new Promise<void>((resolve, reject) => { |
| 160 | + ghPages.publish( |
| 161 | + distDir, |
| 162 | + { |
| 163 | + repo: bareRepoPath, |
| 164 | + branch: 'gh-pages', |
| 165 | + dotfiles: true, |
| 166 | + user: { name: 'Test', email: 'test@test.com' }, |
| 167 | + message: 'baseline: unhooked gh-pages publish' |
| 168 | + }, |
| 169 | + (err: Error | null) => { |
| 170 | + if (err) reject(err); |
| 171 | + else resolve(); |
| 172 | + } |
| 173 | + ); |
| 174 | + }); |
| 175 | + |
| 176 | + const tree = git('ls-tree -r gh-pages --name-only', { cwd: bareRepoPath }) |
| 177 | + .split('\n') |
| 178 | + .filter(Boolean); |
| 179 | + |
| 180 | + // gh-pages' broken remove step missed all of these: |
| 181 | + expect(tree).toContain('.gitignore'); |
| 182 | + expect(tree).toContain('.gitmodules'); |
| 183 | + expect(tree).toContain('.github/workflows/deploy.yml'); |
| 184 | + expect(tree).toContain('build'); |
| 185 | + // Our dist file did land: |
| 186 | + expect(tree).toContain('index.html'); |
| 187 | + // And the non-dot stale file DID get removed by gh-pages' (partial) remove step: |
| 188 | + expect(tree).not.toContain('stale.html'); |
| 189 | + }, 30_000); |
| 190 | +}); |
0 commit comments