+
+
-
-
-
-
+
+
+
+
+
+
-
-
{` ██████╗ ██╗████████╗██╗ ██╗███████╗██████╗ ███████╗███████╗
+
+
+
+
+
+
+ {` ██████╗ ██╗████████╗██╗ ██╗███████╗██████╗ ███████╗███████╗
██╔════╝ ██║╚══██╔══╝██║ ██║██╔════╝██╔══██╗██╔════╝██╔════╝
██║ ███╗██║ ██║ ██║ ██║█████╗ ██████╔╝███████╗█████╗
██║ ██║██║ ██║ ╚██╗ ██╔╝██╔══╝ ██╔══██╗╚════██║██╔══╝
╚██████╔╝██║ ██║ ╚████╔╝ ███████╗██║ ██║███████║███████╗
╚═════╝ ╚═╝ ╚═╝ ╚═══╝ ╚══════╝╚═╝ ╚═╝╚══════╝╚══════╝`}
-
- {` ██████╗ ██╗████████╗██╗ ██╗███████╗██████╗ ███████╗███████╗
+
+ {` ██████╗ ██╗████████╗██╗ ██╗███████╗██████╗ ███████╗███████╗
██╔════╝ ██║╚══██╔══╝██║ ██║██╔════╝██╔══██╗██╔════╝██╔════╝
██║ ███╗██║ ██║ ██║ ██║█████╗ ██████╔╝███████╗█████╗
██║ ██║██║ ██║ ╚██╗ ██╔╝██╔══╝ ██╔══██╗╚════██║██╔══╝
╚██████╔╝██║ ██║ ╚████╔╝ ███████╗██║ ██║███████║███████╗
╚═════╝ ╚═╝ ╚═╝ ╚═══╝ ╚══════╝╚═╝ ╚═╝╚══════╝╚══════╝`}
-
-
-
-
+
+
+
+
-
-
diff --git a/tests/e2e/file-explorer.spec.ts b/tests/e2e/file-explorer.spec.ts
new file mode 100644
index 0000000..d56232e
--- /dev/null
+++ b/tests/e2e/file-explorer.spec.ts
@@ -0,0 +1,63 @@
+import { test, expect } from '@playwright/test';
+
+// Clear persisted state so each test starts from the welcome screen
+// (same rationale as tests/e2e/a11y.spec.ts).
+test.beforeEach(async ({ context, page }) => {
+ await context.addInitScript(() => {
+ try {
+ indexedDB.deleteDatabase('keyval-store');
+ } catch {
+ // ignore – storage may be unavailable in some environments
+ }
+ });
+ await page.goto('./');
+});
+
+test('explorer drives the full beginner flow: seed → stage → commit → modify', async ({ page }) => {
+ const input = page.locator('#terminal-input');
+ const explorer = page.getByRole('complementary', { name: 'File explorer' });
+
+ // Empty state + disabled simulate button.
+ await expect(explorer.getByText(/No files yet/)).toBeVisible();
+ await expect(explorer.getByRole('button', { name: /Simulate changes/ })).toBeDisabled();
+
+ // Seed example files; the real commands echo in the terminal.
+ await explorer.getByRole('button', { name: /Example files/ }).click();
+ await expect(explorer.getByRole('button', { name: 'README.md U' })).toBeVisible();
+ await expect(explorer.getByText('src/')).toBeVisible();
+ await expect(explorer.locator('[data-status="untracked"]')).toHaveCount(3);
+ await expect(page.getByText('mkdir src')).toBeVisible();
+
+ // init + stage all → badges flip to staged.
+ await input.click();
+ await input.fill('git init');
+ await input.press('Enter');
+ await input.fill('git add .');
+ await input.press('Enter');
+ await expect(explorer.locator('[data-status="staged"]')).toHaveCount(3);
+ await expect(explorer.locator('[data-status="untracked"]')).toHaveCount(0);
+
+ // Commit → everything clean; simulate becomes enabled.
+ await input.fill('git commit -m "first"');
+ await input.press('Enter');
+ await expect(explorer.locator('[data-status="clean"]')).toHaveCount(3);
+ const simulate = explorer.getByRole('button', { name: /Simulate changes/ });
+ await expect(simulate).toBeEnabled();
+
+ // Simulate changes → first two files alphabetically become modified.
+ await simulate.click();
+ await expect(explorer.locator('[data-status="modified"]')).toHaveCount(2);
+
+ // Clicking a file prefills cat without executing.
+ await explorer.getByRole('button', { name: /^README\.md/ }).click();
+ await expect(input).toHaveValue('cat README.md');
+ await expect(input).toBeFocused();
+});
+
+test('explorer collapses to a rail and expands again', async ({ page }) => {
+ await expect(page.getByRole('complementary', { name: 'File explorer' })).toBeVisible();
+ await page.getByRole('button', { name: 'Collapse file explorer' }).click();
+ await expect(page.getByRole('complementary', { name: 'File explorer' })).toHaveCount(0);
+ await page.getByRole('button', { name: 'Expand file explorer' }).click();
+ await expect(page.getByRole('complementary', { name: 'File explorer' })).toBeVisible();
+});
diff --git a/tests/shell/builtins.test.ts b/tests/shell/builtins.test.ts
index 4f88c64..ce7ff64 100644
--- a/tests/shell/builtins.test.ts
+++ b/tests/shell/builtins.test.ts
@@ -255,6 +255,56 @@ describe('getDeletedFiles', () => {
});
});
+describe('mkdir', () => {
+ it('creates a directory listable via ls', () => {
+ const r = executeBuiltin(engine, 'mkdir', ['src']);
+ expect(r.exitCode).toBe(0);
+ expect(r.output).toBe('');
+ expect(executeBuiltin(engine, 'ls', []).output).toContain('src/');
+ });
+
+ it('accepts a trailing slash', () => {
+ const r = executeBuiltin(engine, 'mkdir', ['docs/']);
+ expect(r.exitCode).toBe(0);
+ expect(executeBuiltin(engine, 'ls', []).output).toContain('docs/');
+ });
+
+ it('errors on missing operand', () => {
+ const r = executeBuiltin(engine, 'mkdir', []);
+ expect(r.exitCode).toBe(1);
+ expect(r.output).toBe('mkdir: missing operand');
+ });
+
+ it('rejects nested paths (flat + 1 level VFS)', () => {
+ const r = executeBuiltin(engine, 'mkdir', ['a/b']);
+ expect(r.exitCode).toBe(1);
+ expect(r.output).toBe(
+ "mkdir: cannot create directory 'a/b': only one level of nesting is supported",
+ );
+ });
+
+ it('errors when the entry already exists', () => {
+ executeBuiltin(engine, 'mkdir', ['src']);
+ const r = executeBuiltin(engine, 'mkdir', ['src']);
+ expect(r.exitCode).toBe(1);
+ expect(r.output).toBe("mkdir: cannot create directory 'src': File exists");
+ });
+
+ it('errors when a file with the same name exists', () => {
+ engine.getVFS().createFile('src', 'i am a file');
+ const r = executeBuiltin(engine, 'mkdir', ['src']);
+ expect(r.exitCode).toBe(1);
+ expect(r.output).toBe("mkdir: cannot create directory 'src': File exists");
+ });
+
+ it('routes through the shell router and enables touch into it', () => {
+ const router = new ShellRouter(engine);
+ expect(router.execute('mkdir lib').exitCode).toBe(0);
+ expect(router.execute('touch lib/util.js').exitCode).toBe(0);
+ expect(executeBuiltin(engine, 'ls', ['lib']).output).toContain('util.js');
+ });
+});
+
describe('unknown command', () => {
it('returns command not found', () => {
const r = executeBuiltin(engine, 'foobar', []);
diff --git a/tests/shell/parser.test.ts b/tests/shell/parser.test.ts
index 559108c..5aa52a1 100644
--- a/tests/shell/parser.test.ts
+++ b/tests/shell/parser.test.ts
@@ -114,4 +114,9 @@ describe('parseInput – unknown', () => {
args: ['hello'],
});
});
+
+ it('classifies mkdir as a builtin', () => {
+ const p = parseInput('mkdir src');
+ expect(p).toEqual({ type: 'builtin', command: 'mkdir', args: ['src'] });
+ });
});
diff --git a/tests/store/actions.test.ts b/tests/store/actions.test.ts
new file mode 100644
index 0000000..439aa64
--- /dev/null
+++ b/tests/store/actions.test.ts
@@ -0,0 +1,88 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import { GitEngine } from '$engine/index';
+import { ShellRouter } from '$shell/router';
+import { exampleFileCommands, simulateChangeCommands } from '$store/actions';
+
+let engine: GitEngine;
+let router: ShellRouter;
+
+function run(cmd: string) {
+ const r = router.execute(cmd);
+ expect(r.exitCode, `command failed: ${cmd} → ${r.output}`).toBe(0);
+}
+
+beforeEach(() => {
+ engine = new GitEngine();
+ router = new ShellRouter(engine);
+ engine.execute('git init');
+});
+
+describe('exampleFileCommands', () => {
+ it('plans the full starter set on an empty VFS', () => {
+ expect(exampleFileCommands(engine)).toEqual([
+ 'mkdir src',
+ 'touch README.md',
+ 'touch index.html',
+ 'touch src/app.js',
+ ]);
+ });
+
+ it('planned commands all execute successfully through the router', () => {
+ for (const cmd of exampleFileCommands(engine)) run(cmd);
+ expect(engine.getVFS().allFilePaths()).toEqual(
+ ['README.md', 'index.html', 'src/app.js'].sort(),
+ );
+ });
+
+ it('is idempotent — skips whatever already exists', () => {
+ for (const cmd of exampleFileCommands(engine)) run(cmd);
+ expect(exampleFileCommands(engine)).toEqual([]);
+ });
+
+ it('skips only the existing pieces', () => {
+ run('mkdir src');
+ run('touch README.md');
+ expect(exampleFileCommands(engine)).toEqual(['touch index.html', 'touch src/app.js']);
+ });
+});
+
+describe('simulateChangeCommands', () => {
+ it('returns [] when nothing is tracked', () => {
+ engine.getVFS().createFile('a.txt', 'hi'); // untracked
+ expect(simulateChangeCommands(engine)).toEqual([]);
+ });
+
+ it('targets the first two tracked files alphabetically', () => {
+ for (const f of ['b.txt', 'a.txt', 'c.txt']) {
+ run(`touch ${f}`);
+ run(`git add ${f}`);
+ }
+ run('git commit -m "seed"');
+ const cmds = simulateChangeCommands(engine);
+ expect(cmds).toHaveLength(2);
+ expect(cmds[0]).toMatch(/^echo ".+" >> a\.txt$/);
+ expect(cmds[1]).toMatch(/^echo ".+" >> b\.txt$/);
+ });
+
+ it('includes staged-but-uncommitted files as tracked', () => {
+ run('touch a.txt');
+ run('git add a.txt');
+ expect(simulateChangeCommands(engine)).toHaveLength(1);
+ });
+
+ it('skips tracked files deleted from the VFS', () => {
+ run('touch a.txt');
+ run('git add a.txt');
+ run('git commit -m "seed"');
+ engine.getVFS().deleteFile('a.txt');
+ expect(simulateChangeCommands(engine)).toEqual([]);
+ });
+
+ it('planned commands execute and dirty the file', () => {
+ run('touch a.txt');
+ run('git add a.txt');
+ run('git commit -m "seed"');
+ for (const cmd of simulateChangeCommands(engine)) run(cmd);
+ expect(engine.getModifiedFiles()).toEqual(['a.txt']);
+ });
+});
diff --git a/tests/store/files.test.ts b/tests/store/files.test.ts
new file mode 100644
index 0000000..4cdd31d
--- /dev/null
+++ b/tests/store/files.test.ts
@@ -0,0 +1,123 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import { GitEngine } from '$engine/index';
+import { buildFileTree } from '$store/files';
+
+let engine: GitEngine;
+
+beforeEach(() => {
+ engine = new GitEngine();
+ engine.execute('git init');
+});
+
+function flat(engine: GitEngine) {
+ const t = buildFileTree(engine);
+ return [...t.dirs.flatMap((d) => d.files), ...t.rootFiles];
+}
+
+describe('buildFileTree', () => {
+ it('returns an empty model for an empty VFS (hides .git)', () => {
+ const t = buildFileTree(engine);
+ expect(t.rootFiles).toEqual([]);
+ expect(t.dirs).toEqual([]);
+ });
+
+ it('marks a new file untracked', () => {
+ engine.getVFS().createFile('a.txt', 'hi');
+ expect(flat(engine)).toEqual([
+ { path: 'a.txt', name: 'a.txt', dir: null, status: 'untracked' },
+ ]);
+ });
+
+ it('marks an added file staged', () => {
+ engine.getVFS().createFile('a.txt', 'hi');
+ engine.execute('git add a.txt');
+ expect(flat(engine)[0].status).toBe('staged');
+ });
+
+ it('marks a committed file clean, then modified after an edit', () => {
+ engine.getVFS().createFile('a.txt', 'hi');
+ engine.execute('git add a.txt');
+ engine.execute('git commit -m "add"');
+ expect(flat(engine)[0].status).toBe('clean');
+
+ engine.getVFS().createFile('a.txt', 'hi\nedited');
+ expect(flat(engine)[0].status).toBe('modified');
+ });
+
+ it('modified wins over staged when a staged file is re-edited', () => {
+ engine.getVFS().createFile('a.txt', 'v1');
+ engine.execute('git add a.txt');
+ engine.execute('git commit -m "v1"');
+ engine.getVFS().createFile('a.txt', 'v2');
+ engine.execute('git add a.txt'); // staged
+ engine.getVFS().createFile('a.txt', 'v3'); // re-edited after staging
+ expect(flat(engine)[0].status).toBe('modified');
+ });
+
+ it('keeps a deleted tracked file visible with status deleted', () => {
+ engine.getVFS().createFile('a.txt', 'hi');
+ engine.execute('git add a.txt');
+ engine.execute('git commit -m "add"');
+ engine.getVFS().deleteFile('a.txt');
+ expect(flat(engine)).toEqual([{ path: 'a.txt', name: 'a.txt', dir: null, status: 'deleted' }]);
+ });
+
+ it('groups directory files under dirs, root files under rootFiles', () => {
+ engine.getVFS().createDir('src');
+ engine.getVFS().createFile('src/app.js', 'x');
+ engine.getVFS().createFile('README.md', 'x');
+ const t = buildFileTree(engine);
+ expect(t.dirs).toEqual([
+ {
+ name: 'src',
+ files: [{ path: 'src/app.js', name: 'app.js', dir: 'src', status: 'untracked' }],
+ },
+ ]);
+ expect(t.rootFiles.map((f) => f.path)).toEqual(['README.md']);
+ });
+
+ it('lists an empty directory with zero files', () => {
+ engine.getVFS().createDir('empty');
+ const t = buildFileTree(engine);
+ expect(t.dirs).toEqual([{ name: 'empty', files: [] }]);
+ });
+
+ it('still shows the parent dir of a deleted file even if the dir was removed', () => {
+ engine.getVFS().createDir('src');
+ engine.getVFS().createFile('src/app.js', 'x');
+ engine.execute('git add src/app.js');
+ engine.execute('git commit -m "add"');
+ engine.getVFS().deleteFile('src/app.js');
+ engine.getVFS().deleteFile('src/'); // dir entry removed too
+ const t = buildFileTree(engine);
+ expect(t.dirs).toEqual([
+ {
+ name: 'src',
+ files: [{ path: 'src/app.js', name: 'app.js', dir: 'src', status: 'deleted' }],
+ },
+ ]);
+ });
+
+ it('sorts dirs and files alphabetically', () => {
+ engine.getVFS().createDir('zeta');
+ engine.getVFS().createDir('alpha');
+ engine.getVFS().createFile('b.txt', 'x');
+ engine.getVFS().createFile('a.txt', 'x');
+ const t = buildFileTree(engine);
+ expect(t.dirs.map((d) => d.name)).toEqual(['alpha', 'zeta']);
+ expect(t.rootFiles.map((f) => f.name)).toEqual(['a.txt', 'b.txt']);
+ });
+
+ it('shows dot-directories other than .git', () => {
+ engine.getVFS().createDir('.config');
+ const t = buildFileTree(engine);
+ expect(t.dirs).toContainEqual({ name: '.config', files: [] });
+ expect(t.dirs.map((d) => d.name)).not.toContain('.git');
+ });
+
+ it('works before git init (all files untracked)', () => {
+ const fresh = new GitEngine();
+ fresh.getVFS().createFile('a.txt', 'hi');
+ expect(flat(fresh)[0].status).toBe('untracked');
+ });
+});