From 4ee69f857a72b941ce5e46a96cccf29da6af8385 Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Mon, 23 Feb 2026 13:00:48 +0900 Subject: [PATCH] add-e2e-coverage (#364) * takt: add-e2e-coverage * takt: add-e2e-coverage --- e2e/specs/repertoire-real.e2e.ts | 261 +++++++++++++++++++-- e2e/specs/repertoire.e2e.ts | 374 +++++++++++++++++++------------ vitest.config.e2e.mock.ts | 4 +- 3 files changed, 478 insertions(+), 161 deletions(-) diff --git a/e2e/specs/repertoire-real.e2e.ts b/e2e/specs/repertoire-real.e2e.ts index 7f46fc0..5ba95f1 100644 --- a/e2e/specs/repertoire-real.e2e.ts +++ b/e2e/specs/repertoire-real.e2e.ts @@ -1,5 +1,7 @@ +// E2E更新時は docs/testing/e2e.md も更新すること + import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync } from 'node:fs'; import { execFileSync } from 'node:child_process'; import { join } from 'node:path'; import { parse as parseYaml } from 'yaml'; @@ -34,20 +36,43 @@ function canAccessRepoRef(repo: string, ref: string): boolean { } } +function fixtureHasManifest(repo: string, ref: string, filename: string): boolean { + try { + const out = execFileSync( + 'gh', + ['api', `/repos/${repo}/git/trees/${ref}`, '--recursive'], + { encoding: 'utf-8', stdio: 'pipe' }, + ); + const tree = JSON.parse(out) as { tree: { path: string }[] }; + return tree.tree.some((f) => f.path === filename); + } catch { + return false; + } +} + function readYamlFile(path: string): T { const raw = readFileSync(path, 'utf-8'); return parseYaml(raw) as T; } -const FIXTURE_REPO = 'nrslib/takt-ensemble-fixture'; -const FIXTURE_REPO_SUBDIR = 'nrslib/takt-ensemble-fixture-subdir'; -const FIXTURE_REPO_FACETS_ONLY = 'nrslib/takt-ensemble-fixture-facets-only'; +const FIXTURE_REPO = 'nrslib/takt-repertoire-fixture'; +const FIXTURE_REPO_SUBDIR = 'nrslib/takt-repertoire-fixture-subdir'; +const FIXTURE_REPO_FACETS_ONLY = 'nrslib/takt-repertoire-fixture-facets-only'; const MISSING_MANIFEST_REPO = 'nrslib/takt'; const FIXTURE_REF = 'v1.0.0'; -const canUseFixtureRepo = canAccessRepo(FIXTURE_REPO) && canAccessRepoRef(FIXTURE_REPO, FIXTURE_REF); -const canUseSubdirRepo = canAccessRepo(FIXTURE_REPO_SUBDIR) && canAccessRepoRef(FIXTURE_REPO_SUBDIR, FIXTURE_REF); -const canUseFacetsOnlyRepo = canAccessRepo(FIXTURE_REPO_FACETS_ONLY) && canAccessRepoRef(FIXTURE_REPO_FACETS_ONLY, FIXTURE_REF); +const canUseFixtureRepo = + canAccessRepo(FIXTURE_REPO) && + canAccessRepoRef(FIXTURE_REPO, FIXTURE_REF) && + fixtureHasManifest(FIXTURE_REPO, FIXTURE_REF, 'takt-repertoire.yaml'); +const canUseSubdirRepo = + canAccessRepo(FIXTURE_REPO_SUBDIR) && + canAccessRepoRef(FIXTURE_REPO_SUBDIR, FIXTURE_REF) && + fixtureHasManifest(FIXTURE_REPO_SUBDIR, FIXTURE_REF, 'takt-repertoire.yaml'); +const canUseFacetsOnlyRepo = + canAccessRepo(FIXTURE_REPO_FACETS_ONLY) && + canAccessRepoRef(FIXTURE_REPO_FACETS_ONLY, FIXTURE_REF) && + fixtureHasManifest(FIXTURE_REPO_FACETS_ONLY, FIXTURE_REF, 'takt-repertoire.yaml'); const canUseMissingManifestRepo = canAccessRepo(MISSING_MANIFEST_REPO); describe('E2E: takt repertoire (real GitHub fixtures)', () => { @@ -78,14 +103,14 @@ describe('E2E: takt repertoire (real GitHub fixtures)', () => { expect(result.stdout).toContain(`📦 ${FIXTURE_REPO} @${FIXTURE_REF}`); expect(result.stdout).toContain('インストールしました'); - const packageDir = join(isolatedEnv.taktDir, 'repertoire', '@nrslib', 'takt-ensemble-fixture'); + const packageDir = join(isolatedEnv.taktDir, 'repertoire', '@nrslib', 'takt-repertoire-fixture'); expect(existsSync(join(packageDir, 'takt-repertoire.yaml'))).toBe(true); expect(existsSync(join(packageDir, '.takt-repertoire-lock.yaml'))).toBe(true); expect(existsSync(join(packageDir, 'facets'))).toBe(true); expect(existsSync(join(packageDir, 'pieces'))).toBe(true); const lock = readYamlFile(join(packageDir, '.takt-repertoire-lock.yaml')); - expect(lock.source).toBe('github:nrslib/takt-ensemble-fixture'); + expect(lock.source).toBe('github:nrslib/takt-repertoire-fixture'); expect(lock.ref).toBe(FIXTURE_REF); expect(lock.commit).toBeTypeOf('string'); expect(lock.commit!.length).toBeGreaterThanOrEqual(7); @@ -110,7 +135,7 @@ describe('E2E: takt repertoire (real GitHub fixtures)', () => { }); expect(listResult.exitCode).toBe(0); - expect(listResult.stdout).toContain('@nrslib/takt-ensemble-fixture'); + expect(listResult.stdout).toContain('@nrslib/takt-repertoire-fixture'); }, 240_000); it.skipIf(!canUseFixtureRepo)('should remove installed package with confirmation', () => { @@ -124,7 +149,7 @@ describe('E2E: takt repertoire (real GitHub fixtures)', () => { expect(addResult.exitCode).toBe(0); const removeResult = runTakt({ - args: ['repertoire', 'remove', '@nrslib/takt-ensemble-fixture'], + args: ['repertoire', 'remove', '@nrslib/takt-repertoire-fixture'], cwd: process.cwd(), env: isolatedEnv.env, input: 'y\n', @@ -132,7 +157,7 @@ describe('E2E: takt repertoire (real GitHub fixtures)', () => { }); expect(removeResult.exitCode).toBe(0); - const packageDir = join(isolatedEnv.taktDir, 'repertoire', '@nrslib', 'takt-ensemble-fixture'); + const packageDir = join(isolatedEnv.taktDir, 'repertoire', '@nrslib', 'takt-repertoire-fixture'); expect(existsSync(packageDir)).toBe(false); }, 240_000); @@ -148,7 +173,7 @@ describe('E2E: takt repertoire (real GitHub fixtures)', () => { expect(result.exitCode).toBe(0); expect(result.stdout).toContain('キャンセルしました'); - const packageDir = join(isolatedEnv.taktDir, 'repertoire', '@nrslib', 'takt-ensemble-fixture'); + const packageDir = join(isolatedEnv.taktDir, 'repertoire', '@nrslib', 'takt-repertoire-fixture'); expect(existsSync(packageDir)).toBe(false); }, 240_000); @@ -162,7 +187,7 @@ describe('E2E: takt repertoire (real GitHub fixtures)', () => { }); expect(result.exitCode).toBe(0); - const packageDir = join(isolatedEnv.taktDir, 'repertoire', '@nrslib', 'takt-ensemble-fixture-subdir'); + const packageDir = join(isolatedEnv.taktDir, 'repertoire', '@nrslib', 'takt-repertoire-fixture-subdir'); expect(existsSync(join(packageDir, 'takt-repertoire.yaml'))).toBe(true); expect(existsSync(join(packageDir, '.takt-repertoire-lock.yaml'))).toBe(true); expect(existsSync(join(packageDir, 'facets')) || existsSync(join(packageDir, 'pieces'))).toBe(true); @@ -178,7 +203,7 @@ describe('E2E: takt repertoire (real GitHub fixtures)', () => { }); expect(result.exitCode).toBe(0); - const packageDir = join(isolatedEnv.taktDir, 'repertoire', '@nrslib', 'takt-ensemble-fixture-facets-only'); + const packageDir = join(isolatedEnv.taktDir, 'repertoire', '@nrslib', 'takt-repertoire-fixture-facets-only'); expect(existsSync(join(packageDir, 'facets'))).toBe(true); expect(existsSync(join(packageDir, 'pieces'))).toBe(false); }, 240_000); @@ -195,4 +220,210 @@ describe('E2E: takt repertoire (real GitHub fixtures)', () => { expect(result.exitCode).not.toBe(0); expect(result.stdout).toContain('takt-repertoire.yaml not found'); }, 240_000); + + it.skipIf(!canUseFixtureRepo)( + 'should display pre-install summary with package name, faceted info, and pieces list', + () => { + const result = runTakt({ + args: ['repertoire', 'add', `github:${FIXTURE_REPO}@${FIXTURE_REF}`], + cwd: process.cwd(), + env: isolatedEnv.env, + input: 'n\n', + timeout: 240_000, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain(`nrslib/takt-repertoire-fixture @${FIXTURE_REF}`); + expect(result.stdout).toContain('facets:'); + expect(result.stdout).toContain('pieces:'); + expect(result.stdout).toContain('キャンセルしました'); + }, + 240_000, + ); + + it.skipIf(!canUseFixtureRepo)( + 'should display already-installed warning when adding a package that is already installed', + () => { + const firstResult = runTakt({ + args: ['repertoire', 'add', `github:${FIXTURE_REPO}@${FIXTURE_REF}`], + cwd: process.cwd(), + env: isolatedEnv.env, + input: 'y\n', + timeout: 240_000, + }); + expect(firstResult.exitCode).toBe(0); + + const secondResult = runTakt({ + args: ['repertoire', 'add', `github:${FIXTURE_REPO}@${FIXTURE_REF}`], + cwd: process.cwd(), + env: isolatedEnv.env, + input: 'y\nn\n', + timeout: 240_000, + }); + + expect(secondResult.exitCode).toBe(0); + expect(secondResult.stdout).toContain('既にインストールされています'); + }, + 240_000, + ); + + it.skipIf(!canUseFixtureRepo)( + 'should atomically update package with no leftover tmp or bak directories when user answers y to overwrite', + () => { + const firstResult = runTakt({ + args: ['repertoire', 'add', `github:${FIXTURE_REPO}@${FIXTURE_REF}`], + cwd: process.cwd(), + env: isolatedEnv.env, + input: 'y\n', + timeout: 240_000, + }); + expect(firstResult.exitCode).toBe(0); + + const packageDir = join( + isolatedEnv.taktDir, + 'repertoire', + '@nrslib', + 'takt-repertoire-fixture', + ); + + const secondResult = runTakt({ + args: ['repertoire', 'add', `github:${FIXTURE_REPO}@${FIXTURE_REF}`], + cwd: process.cwd(), + env: isolatedEnv.env, + input: 'y\ny\n', + timeout: 240_000, + }); + + expect(secondResult.exitCode).toBe(0); + expect(existsSync(`${packageDir}.tmp`)).toBe(false); + expect(existsSync(`${packageDir}.bak`)).toBe(false); + expect(existsSync(join(packageDir, '.takt-repertoire-lock.yaml'))).toBe(true); + }, + 240_000, + ); + + it.skipIf(!canUseFixtureRepo)( + 'should keep existing package unchanged when user answers N to overwrite prompt', + () => { + const firstResult = runTakt({ + args: ['repertoire', 'add', `github:${FIXTURE_REPO}@${FIXTURE_REF}`], + cwd: process.cwd(), + env: isolatedEnv.env, + input: 'y\n', + timeout: 240_000, + }); + expect(firstResult.exitCode).toBe(0); + + const lockPath = join( + isolatedEnv.taktDir, + 'repertoire', + '@nrslib', + 'takt-repertoire-fixture', + '.takt-repertoire-lock.yaml', + ); + const originalLock = readYamlFile(lockPath); + + const secondResult = runTakt({ + args: ['repertoire', 'add', `github:${FIXTURE_REPO}@${FIXTURE_REF}`], + cwd: process.cwd(), + env: isolatedEnv.env, + input: 'y\nn\n', + timeout: 240_000, + }); + + expect(secondResult.exitCode).toBe(0); + const afterLock = readYamlFile(lockPath); + expect(afterLock.commit).toBe(originalLock.commit); + expect(afterLock.imported_at).toBe(originalLock.imported_at); + }, + 240_000, + ); + + it.skipIf(!canUseFixtureRepo)( + 'should clean up leftover .tmp/ directory from previous failed installation and succeed', + () => { + const firstResult = runTakt({ + args: ['repertoire', 'add', `github:${FIXTURE_REPO}@${FIXTURE_REF}`], + cwd: process.cwd(), + env: isolatedEnv.env, + input: 'y\n', + timeout: 240_000, + }); + expect(firstResult.exitCode).toBe(0); + + const packageDir = join( + isolatedEnv.taktDir, + 'repertoire', + '@nrslib', + 'takt-repertoire-fixture', + ); + + mkdirSync(`${packageDir}.tmp`, { recursive: true }); + + const secondResult = runTakt({ + args: ['repertoire', 'add', `github:${FIXTURE_REPO}@${FIXTURE_REF}`], + cwd: process.cwd(), + env: isolatedEnv.env, + input: 'y\ny\n', + timeout: 240_000, + }); + + expect(secondResult.exitCode).toBe(0); + expect(existsSync(`${packageDir}.tmp`)).toBe(false); + }, + 240_000, + ); + + it.skipIf(!canUseFixtureRepo)( + 'should clean up leftover .bak/ directory from previous failed installation and succeed', + () => { + const firstResult = runTakt({ + args: ['repertoire', 'add', `github:${FIXTURE_REPO}@${FIXTURE_REF}`], + cwd: process.cwd(), + env: isolatedEnv.env, + input: 'y\n', + timeout: 240_000, + }); + expect(firstResult.exitCode).toBe(0); + + const packageDir = join( + isolatedEnv.taktDir, + 'repertoire', + '@nrslib', + 'takt-repertoire-fixture', + ); + + mkdirSync(`${packageDir}.bak`, { recursive: true }); + + const secondResult = runTakt({ + args: ['repertoire', 'add', `github:${FIXTURE_REPO}@${FIXTURE_REF}`], + cwd: process.cwd(), + env: isolatedEnv.env, + input: 'y\ny\n', + timeout: 240_000, + }); + + expect(secondResult.exitCode).toBe(0); + expect(existsSync(`${packageDir}.bak`)).toBe(false); + }, + 240_000, + ); + + it.todo('should populate lock file commit field with the specified commit SHA when installing by SHA'); + + it.todo('should display warning symbol when package contains piece with edit: true'); + + it.todo('should reject takt-repertoire.yaml with absolute path in path field (/foo)'); + + it.todo('should reject takt-repertoire.yaml with path traversal via ".." segments'); + + it.todo('should reject package with neither facets/ nor pieces/ directory'); + + it.todo('should reject takt-repertoire.yaml with min_version "1.0" (missing patch segment)'); + + it.todo('should reject takt-repertoire.yaml with min_version "v1.0.0" (v prefix)'); + + it.todo('should reject takt-repertoire.yaml with min_version "1.0.0-alpha" (pre-release suffix)'); + + it.todo('should fail with version mismatch message when min_version exceeds current takt version'); }); diff --git a/e2e/specs/repertoire.e2e.ts b/e2e/specs/repertoire.e2e.ts index aac3748..ede2f4d 100644 --- a/e2e/specs/repertoire.e2e.ts +++ b/e2e/specs/repertoire.e2e.ts @@ -1,71 +1,63 @@ -/** - * E2E tests for `takt repertoire` subcommands. - * - * All tests are marked as `it.todo()` because the `takt repertoire` command - * is not yet implemented. These serve as the specification skeleton; - * fill in the callbacks when the implementation lands. - * - * GitHub fixture repos used: - * - github:nrslib/takt-ensemble-fixture (standard: facets/ + pieces/) - * - github:nrslib/takt-ensemble-fixture-subdir (path field specified) - * - github:nrslib/takt-ensemble-fixture-facets-only (facets only, no pieces/) - * - */ +// E2E更新時は docs/testing/e2e.md も更新すること -import { describe, it } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { stringify as stringifyYaml } from 'yaml'; +import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; +import { runTakt } from '../helpers/takt-runner'; + +interface FakePackageOptions { + taktDir: string; + owner: string; + repo: string; + description?: string; + ref?: string; + commit?: string; +} + +function createFakePackage(opts: FakePackageOptions): string { + const { taktDir, owner, repo } = opts; + const ref = opts.ref ?? 'v1.0.0'; + const commit = opts.commit ?? 'abc1234def5678'; + const packageDir = join(taktDir, 'repertoire', `@${owner}`, repo); + + mkdirSync(packageDir, { recursive: true }); + + const manifest: Record = { path: '.' }; + if (opts.description) manifest.description = opts.description; + writeFileSync(join(packageDir, 'takt-repertoire.yaml'), stringifyYaml(manifest)); + + const lock = { + source: `github:${owner}/${repo}`, + ref, + commit, + imported_at: new Date().toISOString(), + }; + writeFileSync(join(packageDir, '.takt-repertoire-lock.yaml'), stringifyYaml(lock)); + + return packageDir; +} // --------------------------------------------------------------------------- // E2E: takt repertoire add — 正常系 // --------------------------------------------------------------------------- describe('E2E: takt repertoire add (正常系)', () => { - // E1: 標準パッケージのインポート - // Given: 空の isolatedEnv - // When: takt repertoire add github:nrslib/takt-ensemble-fixture@v1.0.0、y 入力 - // Then: {taktDir}/repertoire/@nrslib/takt-ensemble-fixture/ に takt-repertoire.yaml, - // .takt-repertoire-lock.yaml, facets/, pieces/ が存在する it.todo('should install standard package and verify directory structure'); - // E2: lock ファイルのフィールド確認 - // Given: E1 完了後 - // When: .takt-repertoire-lock.yaml を読む - // Then: source, ref, commit, imported_at フィールドがすべて存在する it.todo('should generate .takt-repertoire-lock.yaml with source, ref, commit, imported_at'); - // E3: サブディレクトリ型パッケージのインポート - // Given: 空の isolatedEnv - // When: takt repertoire add github:nrslib/takt-ensemble-fixture-subdir@v1.0.0、y 入力 - // Then: path フィールドで指定されたサブディレクトリ配下のファイルのみコピーされる it.todo('should install subdir-type package and copy only path-specified files'); - // E4: ファセットのみパッケージのインポート - // Given: 空の isolatedEnv - // When: takt repertoire add github:nrslib/takt-ensemble-fixture-facets-only@v1.0.0、y 入力 - // Then: facets/ は存在し、pieces/ ディレクトリは存在しない it.todo('should install facets-only package without creating pieces/ directory'); - // E4b: コミットSHA指定 - // Given: 空の isolatedEnv - // When: takt repertoire add github:nrslib/takt-ensemble-fixture@{sha}、y 入力 - // Then: .takt-repertoire-lock.yaml の commit フィールドが指定した SHA と一致する it.todo('should populate lock file commit field with the specified commit SHA when installing by SHA'); - // E5: インストール前サマリー表示 - // Given: 空の isolatedEnv - // When: takt repertoire add github:nrslib/takt-ensemble-fixture@v1.0.0、N 入力(確認でキャンセル) - // Then: stdout に "📦 nrslib/takt-ensemble-fixture", "faceted:", "pieces:" が含まれる it.todo('should display pre-install summary with package name, faceted count, and pieces list'); - // E6: 権限警告表示(edit: true ピース) - // Given: edit: true を含むパッケージ - // When: repertoire add、N 入力 - // Then: stdout に ⚠ が含まれる it.todo('should display warning symbol when package contains piece with edit: true'); - // E7: ユーザー確認 N で中断 - // Given: 空の isolatedEnv - // When: repertoire add、N 入力 - // Then: インストールディレクトリが存在しない。exit code 0 it.todo('should abort installation when user answers N to confirmation prompt'); }); @@ -74,34 +66,14 @@ describe('E2E: takt repertoire add (正常系)', () => { // --------------------------------------------------------------------------- describe('E2E: takt repertoire add (上書きシナリオ)', () => { - // E8: 既存パッケージの上書き警告表示 - // Given: 1回目インストール済み - // When: 2回目 repertoire add - // Then: stdout に "⚠ パッケージ @nrslib/takt-ensemble-fixture は既にインストールされています" が含まれる it.todo('should display already-installed warning on second add'); - // E9: 上書き y で原子的更新 - // Given: E8後、y 入力 - // When: インストール完了後 - // Then: .tmp/, .bak/ が残っていない。新 lock ファイルが配置済み it.todo('should atomically update package when user answers y to overwrite prompt'); - // E10: 上書き N でキャンセル - // Given: E8後、N 入力 - // When: コマンド終了後 - // Then: 既存パッケージが維持される(元 lock ファイルが変わらない) it.todo('should keep existing package when user answers N to overwrite prompt'); - // E11: 前回異常終了残留物(.tmp/)クリーンアップ - // Given: {repertoireDir}/@nrslib/takt-ensemble-fixture.tmp/ が既に存在する状態 - // When: repertoire add、y 入力 - // Then: インストールが正常完了する。exit code 0 it.todo('should clean up leftover .tmp/ directory from previous failed installation'); - // E12: 前回異常終了残留物(.bak/)クリーンアップ - // Given: {repertoireDir}/@nrslib/takt-ensemble-fixture.bak/ が既に存在する状態 - // When: repertoire add、y 入力 - // Then: インストールが正常完了する。exit code 0 it.todo('should clean up leftover .bak/ directory from previous failed installation'); }); @@ -110,112 +82,226 @@ describe('E2E: takt repertoire add (上書きシナリオ)', () => { // --------------------------------------------------------------------------- describe('E2E: takt repertoire add (バリデーション・エラー系)', () => { - // E13: takt-repertoire.yaml 不在リポジトリ - // Given: takt-repertoire.yaml のないリポジトリを指定 - // When: repertoire add - // Then: exit code 非0。エラーメッセージ表示 it.todo('should fail with error when repository has no takt-repertoire.yaml'); - // E14: path に絶対パス(/foo) - // Given: path: /foo の takt-repertoire.yaml - // When: repertoire add - // Then: exit code 非0 it.todo('should reject takt-repertoire.yaml with absolute path in path field (/foo)'); - // E15: path に .. によるリポジトリ外参照 - // Given: path: ../outside の takt-repertoire.yaml - // When: repertoire add - // Then: exit code 非0 it.todo('should reject takt-repertoire.yaml with path traversal via ".." segments'); - // E16: 空パッケージ(facets/ も pieces/ もない) - // Given: facets/, pieces/ のどちらもない takt-repertoire.yaml - // When: repertoire add - // Then: exit code 非0 it.todo('should reject package with neither facets/ nor pieces/ directory'); - // E17: min_version 不正形式(1.0、セグメント不足) - // Given: takt.min_version: "1.0" - // When: repertoire add - // Then: exit code 非0 it.todo('should reject takt-repertoire.yaml with min_version "1.0" (missing patch segment)'); - // E18: min_version 不正形式(v1.0.0、v プレフィックス) - // Given: takt.min_version: "v1.0.0" - // When: repertoire add - // Then: exit code 非0 it.todo('should reject takt-repertoire.yaml with min_version "v1.0.0" (v prefix)'); - // E19: min_version 不正形式(1.0.0-alpha、pre-release) - // Given: takt.min_version: "1.0.0-alpha" - // When: repertoire add - // Then: exit code 非0 it.todo('should reject takt-repertoire.yaml with min_version "1.0.0-alpha" (pre-release suffix)'); - // E20: min_version が現在の TAKT より新しい - // Given: takt.min_version: "999.0.0" - // When: repertoire add - // Then: exit code 非0。必要バージョンと現在バージョンが表示される it.todo('should fail with version mismatch message when min_version exceeds current takt version'); }); // --------------------------------------------------------------------------- -// E2E: takt repertoire remove +// E2E: takt repertoire remove (mock) // --------------------------------------------------------------------------- -describe('E2E: takt repertoire remove', () => { - // E21: 正常削除 y - // Given: パッケージインストール済み - // When: takt repertoire remove @nrslib/takt-ensemble-fixture、y 入力 - // Then: ディレクトリが削除される。@nrslib/ 配下が空なら @nrslib/ も削除 - it.todo('should remove installed package directory when user answers y'); +describe('E2E: takt repertoire remove (mock)', () => { + let isolatedEnv: IsolatedEnv; - // E22: owner dir 残存(他パッケージがある場合) - // Given: @nrslib 配下に別パッケージもインストール済み - // When: remove、y 入力 - // Then: 対象パッケージのみ削除。@nrslib/ は残る - it.todo('should keep @scope directory when other packages remain under same owner'); + beforeEach(() => { + isolatedEnv = createIsolatedEnv(); + }); - // E23: 参照ありでの警告付き削除 - // Given: ~/.takt/pieces/ に @scope 参照するファイルあり - // When: remove、y 入力 - // Then: 警告("⚠ 次のファイルが...を参照しています")が表示され、削除は実行される - it.todo('should display reference warning before deletion but still proceed when user answers y'); + afterEach(() => { + try { + isolatedEnv.cleanup(); + } catch { + // best-effort + } + }); - // E24: 参照ファイル自体は変更されない - // Given: E23後 - // When: 参照ファイルを読む - // Then: 元の @scope 参照がそのまま残っている - it.todo('should not modify reference files during removal'); + it('should remove installed package and delete empty owner directory when user answers y', () => { + const scope = '@testowner/single-fixture'; + createFakePackage({ taktDir: isolatedEnv.taktDir, owner: 'testowner', repo: 'single-fixture' }); - // E25: 削除キャンセル N - // Given: パッケージインストール済み - // When: remove、N 入力 - // Then: ディレクトリが残る。exit code 0 - it.todo('should keep package directory when user answers N to removal prompt'); + const result = runTakt({ + args: ['repertoire', 'remove', scope], + cwd: process.cwd(), + env: isolatedEnv.env, + input: 'y\n', + timeout: 30_000, + }); + + expect(result.exitCode).toBe(0); + const packageDir = join(isolatedEnv.taktDir, 'repertoire', '@testowner', 'single-fixture'); + const ownerDir = join(isolatedEnv.taktDir, 'repertoire', '@testowner'); + expect(existsSync(packageDir)).toBe(false); + expect(existsSync(ownerDir)).toBe(false); + }, 240_000); + + it('should keep owner directory when other packages remain under same scope', () => { + createFakePackage({ taktDir: isolatedEnv.taktDir, owner: 'testowner', repo: 'fixture-a' }); + createFakePackage({ taktDir: isolatedEnv.taktDir, owner: 'testowner', repo: 'fixture-b' }); + + const result = runTakt({ + args: ['repertoire', 'remove', '@testowner/fixture-a'], + cwd: process.cwd(), + env: isolatedEnv.env, + input: 'y\n', + timeout: 30_000, + }); + + expect(result.exitCode).toBe(0); + const removedDir = join(isolatedEnv.taktDir, 'repertoire', '@testowner', 'fixture-a'); + const ownerDir = join(isolatedEnv.taktDir, 'repertoire', '@testowner'); + const remainingDir = join(isolatedEnv.taktDir, 'repertoire', '@testowner', 'fixture-b'); + expect(existsSync(removedDir)).toBe(false); + expect(existsSync(ownerDir)).toBe(true); + expect(existsSync(remainingDir)).toBe(true); + }, 240_000); + + it('should display reference warning before deletion but still proceed when user answers y', () => { + const scope = '@testowner/ref-fixture'; + createFakePackage({ taktDir: isolatedEnv.taktDir, owner: 'testowner', repo: 'ref-fixture' }); + + const piecesDir = join(isolatedEnv.taktDir, 'pieces'); + mkdirSync(piecesDir, { recursive: true }); + writeFileSync(join(piecesDir, 'ref-piece.yaml'), `from: ${scope}\nname: example\n`); + + const result = runTakt({ + args: ['repertoire', 'remove', scope], + cwd: process.cwd(), + env: isolatedEnv.env, + input: 'y\n', + timeout: 30_000, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('⚠ 以下のファイルが'); + expect(result.stdout).toContain('を参照しています'); + const packageDir = join(isolatedEnv.taktDir, 'repertoire', '@testowner', 'ref-fixture'); + expect(existsSync(packageDir)).toBe(false); + }, 240_000); + + it('should not modify reference files during package removal', () => { + const scope = '@testowner/ref-fixture2'; + createFakePackage({ taktDir: isolatedEnv.taktDir, owner: 'testowner', repo: 'ref-fixture2' }); + + const piecesDir = join(isolatedEnv.taktDir, 'pieces'); + mkdirSync(piecesDir, { recursive: true }); + const refFilePath = join(piecesDir, 'ref-piece2.yaml'); + const originalContent = `from: ${scope}\nname: example\n`; + writeFileSync(refFilePath, originalContent); + + const removeResult = runTakt({ + args: ['repertoire', 'remove', scope], + cwd: process.cwd(), + env: isolatedEnv.env, + input: 'y\n', + timeout: 30_000, + }); + expect(removeResult.exitCode).toBe(0); + + const afterContent = readFileSync(refFilePath, 'utf-8'); + expect(afterContent).toBe(originalContent); + }, 240_000); + + it('should keep package directory when user answers N to removal prompt', () => { + const scope = '@testowner/keep-fixture'; + createFakePackage({ taktDir: isolatedEnv.taktDir, owner: 'testowner', repo: 'keep-fixture' }); + + const result = runTakt({ + args: ['repertoire', 'remove', scope], + cwd: process.cwd(), + env: isolatedEnv.env, + input: 'n\n', + timeout: 30_000, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('キャンセルしました'); + const packageDir = join(isolatedEnv.taktDir, 'repertoire', '@testowner', 'keep-fixture'); + expect(existsSync(packageDir)).toBe(true); + }, 240_000); }); // --------------------------------------------------------------------------- -// E2E: takt repertoire list +// E2E: takt repertoire list (mock) // --------------------------------------------------------------------------- -describe('E2E: takt repertoire list', () => { - // E26: インストール済みパッケージ一覧表示 - // Given: パッケージ1件インストール済み - // When: takt repertoire list - // Then: "📦 インストール済みパッケージ:" と @nrslib/takt-ensemble-fixture、 - // description、ref、commit 先頭7文字が表示される - it.todo('should list installed packages with name, description, ref, and abbreviated commit'); +describe('E2E: takt repertoire list (mock)', () => { + let isolatedEnv: IsolatedEnv; - // E27: 空状態での表示 - // Given: repertoire/ が空(パッケージなし) - // When: takt repertoire list - // Then: パッケージなし相当のメッセージ。exit code 0 - it.todo('should display empty-state message when no packages are installed'); + beforeEach(() => { + isolatedEnv = createIsolatedEnv(); + }); - // E28: 複数パッケージの一覧 - // Given: 2件以上インストール済み - // When: takt repertoire list - // Then: すべてのパッケージが表示される - it.todo('should list all installed packages when multiple packages exist'); + afterEach(() => { + try { + isolatedEnv.cleanup(); + } catch { + // best-effort + } + }); + + it('should list installed package with name, description, ref, and abbreviated commit', () => { + createFakePackage({ + taktDir: isolatedEnv.taktDir, + owner: 'testowner', + repo: 'list-fixture', + description: 'My test package', + ref: 'v2.0.0', + commit: 'abcdef1234567', + }); + + const result = runTakt({ + args: ['repertoire', 'list'], + cwd: process.cwd(), + env: isolatedEnv.env, + timeout: 30_000, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('📦 インストール済みパッケージ:'); + expect(result.stdout).toContain('@testowner/list-fixture'); + expect(result.stdout).toContain('My test package'); + expect(result.stdout).toContain('v2.0.0'); + expect(result.stdout).toContain('abcdef1'); + }, 240_000); + + it('should display empty-state message when no packages are installed', () => { + const result = runTakt({ + args: ['repertoire', 'list'], + cwd: process.cwd(), + env: isolatedEnv.env, + timeout: 30_000, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('インストール済みパッケージはありません'); + }, 240_000); + + it('should list all installed packages when multiple packages exist', () => { + createFakePackage({ + taktDir: isolatedEnv.taktDir, + owner: 'ownerA', + repo: 'pkg-alpha', + description: 'Alpha package', + }); + createFakePackage({ + taktDir: isolatedEnv.taktDir, + owner: 'ownerB', + repo: 'pkg-beta', + description: 'Beta package', + }); + + const result = runTakt({ + args: ['repertoire', 'list'], + cwd: process.cwd(), + env: isolatedEnv.env, + timeout: 30_000, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('@ownerA/pkg-alpha'); + expect(result.stdout).toContain('@ownerB/pkg-beta'); + }, 240_000); }); diff --git a/vitest.config.e2e.mock.ts b/vitest.config.e2e.mock.ts index 7a7cc28..8705ee6 100644 --- a/vitest.config.e2e.mock.ts +++ b/vitest.config.e2e.mock.ts @@ -36,8 +36,8 @@ export default defineConfig({ 'e2e/specs/quiet-mode.e2e.ts', 'e2e/specs/task-content-file.e2e.ts', 'e2e/specs/config-priority.e2e.ts', - 'e2e/specs/ensemble.e2e.ts', - 'e2e/specs/ensemble-real.e2e.ts', + 'e2e/specs/repertoire.e2e.ts', + 'e2e/specs/repertoire-real.e2e.ts', 'e2e/specs/piece-selection-branches.e2e.ts', ], },