Skip to content

Commit 310434d

Browse files
authored
Merge pull request #3 from livesession/feat/git-native
feat(git-native): 'native' git impl
2 parents 14fa75d + 87a61b5 commit 310434d

31 files changed

Lines changed: 975 additions & 115 deletions

.github/workflows/tests.yml

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
name: Tests
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
unit:
11+
name: Unit Tests
12+
runs-on: ubuntu-latest
13+
steps:
14+
- uses: actions/checkout@v4
15+
16+
- uses: pnpm/action-setup@v4
17+
with:
18+
version: 9
19+
20+
- uses: actions/setup-node@v4
21+
with:
22+
node-version: 22
23+
cache: pnpm
24+
25+
- run: pnpm install
26+
- run: pnpm run build:lib
27+
- run: pnpm test
28+
29+
e2e:
30+
name: E2E Tests
31+
runs-on: ubuntu-latest
32+
steps:
33+
- uses: actions/checkout@v4
34+
35+
- uses: pnpm/action-setup@v4
36+
with:
37+
version: 9
38+
39+
- uses: actions/setup-node@v4
40+
with:
41+
node-version: 22
42+
cache: pnpm
43+
44+
- run: pnpm install
45+
- run: pnpm run build:lib
46+
- run: npx playwright install --with-deps chromium
47+
- run: pnpm test:e2e

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,5 +35,8 @@ pnpm-debug.log*
3535
# Test coverage
3636
coverage/
3737

38+
# Test results
39+
test-results/
40+
3841
# Local research / planning notes (Claude Code scratch)
3942
memory/

package.json

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@
8484
"build:publish": "npm run build:lib && npm run build:types",
8585
"type-check": "tsc --noEmit",
8686
"test": "vitest run",
87+
"test:e2e": "npx playwright test",
8788
"test:watch": "vitest",
8889
"bench": "vitest bench",
8990
"bench:run": "vitest bench --run",
@@ -95,17 +96,19 @@
9596
"brotli": "^1.3.3",
9697
"brotli-wasm": "^3.0.1",
9798
"comlink": "^4.4.2",
99+
"isomorphic-git": "^1.37.5",
98100
"pako": "^2.1.0",
99101
"resolve.exports": "^2.0.3",
102+
"sha.js": "^2.4.12",
100103
"zod": "^4.3.6"
101104
},
102105
"peerDependencies": {
103-
"@xterm/xterm": "^6.0.0",
104106
"@xterm/addon-fit": "^0.11.0",
105107
"@xterm/addon-webgl": "^0.19.0",
106108
"@xterm/addon-serialize": "^0.14.0",
107-
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
108-
"next": "^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0"
109+
"@xterm/xterm": "^6.0.0",
110+
"next": "^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0",
111+
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
109112
},
110113
"peerDependenciesMeta": {
111114
"@xterm/xterm": {
@@ -131,10 +134,14 @@
131134
"@types/node": "^25.0.10",
132135
"@types/pako": "^2.0.4",
133136
"esbuild": "^0.27.2",
137+
"tsx": "^4.21.0",
134138
"typescript": "^5.9.3",
135-
"vite": "^5.4.0",
139+
"@playwright/test": "^1.53.1",
140+
"@vitest/browser": "^4.1.5",
141+
"playwright": "^1.53.1",
142+
"vite": "^6.0.0",
136143
"vite-plugin-top-level-await": "^1.6.0",
137144
"vite-plugin-wasm": "^3.5.0",
138-
"vitest": "^4.0.18"
145+
"vitest": "^4.1.5"
139146
}
140147
}

playwright.config.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { defineConfig } from '@playwright/test';
2+
3+
export default defineConfig({
4+
testDir: './src/__tests__/e2e',
5+
timeout: 60000,
6+
projects: [
7+
{
8+
name: 'git-clone',
9+
testDir: './src/__tests__/e2e/git-clone',
10+
testMatch: '*.test.ts',
11+
use: { baseURL: 'http://localhost:4567' },
12+
},
13+
{
14+
name: 'git-clone-api',
15+
testDir: './src/__tests__/e2e/git-clone-api',
16+
testMatch: '*.test.ts',
17+
use: { baseURL: 'http://localhost:4568' },
18+
},
19+
],
20+
webServer: [
21+
{
22+
command: 'npx vite --config src/__tests__/e2e/git-clone/vite.config.ts',
23+
port: 4567,
24+
reuseExistingServer: true,
25+
timeout: 30000,
26+
},
27+
{
28+
command: 'npx vite --config src/__tests__/e2e/git-clone-api/vite.config.ts',
29+
port: 4568,
30+
reuseExistingServer: true,
31+
timeout: 30000,
32+
},
33+
],
34+
});
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { test, expect } from '@playwright/test';
2+
import { startMockGithub } from './mock-github';
3+
import { boot, gitClone, listFiles, readFile } from '../utils/page-helpers';
4+
import type { Server } from 'node:http';
5+
6+
let mockServer: Server;
7+
8+
test.beforeAll(async () => {
9+
mockServer = await startMockGithub(4569);
10+
});
11+
12+
test.afterAll(async () => {
13+
mockServer?.close();
14+
});
15+
16+
test.describe('git clone API mode', () => {
17+
test.beforeEach(async ({ page }) => {
18+
await page.goto('http://localhost:4568');
19+
expect(await boot(page)).toBe(true);
20+
});
21+
22+
test('clones mock repo and checks out files', async ({ page }) => {
23+
const result = await gitClone(page, 'https://github.com/test-org/test-repo');
24+
expect(result.exitCode).toBe(0);
25+
26+
const files = await listFiles(page, '/workspace/test-repo');
27+
expect(files).toContain('package.json');
28+
expect(files).toContain('README.md');
29+
30+
const pkgContent = await readFile(page, '/workspace/test-repo/package.json');
31+
expect(pkgContent).toBeTruthy();
32+
const pkg = JSON.parse(pkgContent);
33+
expect(pkg.name).toBe('test-repo');
34+
});
35+
36+
test('clones mock repo with subdirectories', async ({ page }) => {
37+
const result = await gitClone(page, 'https://github.com/test-org/test-repo');
38+
expect(result.exitCode).toBe(0);
39+
40+
const content = await readFile(page, '/workspace/test-repo/src/index.ts');
41+
expect(content).toBeTruthy();
42+
expect(content).toContain('hello');
43+
});
44+
45+
test('README.md has expected content', async ({ page }) => {
46+
await gitClone(page, 'https://github.com/test-org/test-repo');
47+
const readme = await readFile(page, '/workspace/test-repo/README.md');
48+
expect(readme).toContain('mock repository');
49+
});
50+
51+
test('clone non-existent repo returns error', async ({ page }) => {
52+
const result = await gitClone(page, 'https://github.com/test-org/nonexistent-repo');
53+
expect(result.exitCode).not.toBe(0);
54+
});
55+
});
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head><title>nodepod git-clone-api e2e</title></head>
4+
<body>
5+
<pre id="output"></pre>
6+
<script type="module" src="./main.ts"></script>
7+
</body>
8+
</html>
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { Nodepod } from '@scelar/nodepod';
2+
import { createTestApi } from '../utils/test-api';
3+
4+
const output = document.getElementById('output')!;
5+
const log = (msg: string) => { output.textContent += msg + '\n'; console.log(msg); };
6+
7+
(window as any).__nodepodTest = createTestApi(
8+
() => Nodepod.boot({
9+
workdir: '/workspace',
10+
git: 'api',
11+
gitApiBase: 'http://localhost:4569/api',
12+
gitRawBase: 'http://localhost:4569/raw',
13+
serviceWorker: false,
14+
watermark: false,
15+
}),
16+
log,
17+
);
18+
19+
log('[test] fixture loaded (API mode)');
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* Mock GitHub API server for testing git clone in API mode.
3+
* Serves fake repo metadata, tree, and file contents.
4+
*/
5+
import http from 'node:http';
6+
7+
const MOCK_OWNER = 'test-org';
8+
const MOCK_REPO = 'test-repo';
9+
10+
const files: Record<string, string> = {
11+
'package.json': JSON.stringify({ name: 'test-repo', version: '1.0.0', private: true }, null, 2),
12+
'README.md': '# Test Repo\n\nThis is a mock repository for testing git clone API mode.',
13+
'src/index.ts': 'export const hello = "world";\n',
14+
};
15+
16+
function handleRequest(req: http.IncomingMessage, res: http.ServerResponse) {
17+
res.setHeader('Access-Control-Allow-Origin', '*');
18+
res.setHeader('Access-Control-Allow-Headers', '*');
19+
20+
if (req.method === 'OPTIONS') {
21+
res.writeHead(204);
22+
res.end();
23+
return;
24+
}
25+
26+
const url = req.url || '';
27+
28+
// GET /api/repos/:owner/:repo
29+
if (url === `/api/repos/${MOCK_OWNER}/${MOCK_REPO}`) {
30+
res.writeHead(200, { 'Content-Type': 'application/json' });
31+
res.end(JSON.stringify({
32+
default_branch: 'main',
33+
name: MOCK_REPO,
34+
full_name: `${MOCK_OWNER}/${MOCK_REPO}`,
35+
}));
36+
return;
37+
}
38+
39+
// GET /api/repos/:owner/:repo/git/trees/main?recursive=1
40+
if (url.startsWith(`/api/repos/${MOCK_OWNER}/${MOCK_REPO}/git/trees/`)) {
41+
const tree = Object.keys(files).map(path => ({
42+
path,
43+
type: 'blob',
44+
sha: 'abc123',
45+
}));
46+
res.writeHead(200, { 'Content-Type': 'application/json' });
47+
res.end(JSON.stringify({ tree }));
48+
return;
49+
}
50+
51+
// GET /raw/:owner/:repo/:branch/:path — raw file content
52+
const rawMatch = url.match(/^\/raw\/[^/]+\/[^/]+\/[^/]+\/(.+)$/);
53+
if (rawMatch) {
54+
const filePath = rawMatch[1];
55+
if (files[filePath]) {
56+
res.writeHead(200, { 'Content-Type': 'text/plain' });
57+
res.end(files[filePath]);
58+
return;
59+
}
60+
}
61+
62+
// 404
63+
res.writeHead(404, { 'Content-Type': 'application/json' });
64+
res.end(JSON.stringify({ message: 'Not Found' }));
65+
}
66+
67+
export function startMockGithub(port: number): Promise<http.Server> {
68+
return new Promise((resolve) => {
69+
const server = http.createServer(handleRequest);
70+
server.listen(port, () => resolve(server));
71+
});
72+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { defineConfig } from 'vite';
2+
import { resolve } from 'path';
3+
import nodepod from '@scelar/nodepod/vite';
4+
5+
export default defineConfig({
6+
root: __dirname,
7+
plugins: [nodepod()], // API mode — no git: 'native'
8+
server: {
9+
port: 4568,
10+
},
11+
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { test, expect } from '@playwright/test';
2+
import { boot, gitClone, listFiles, readFile, fileExists } from '../utils/page-helpers';
3+
4+
test.describe('git clone e2e', () => {
5+
test.beforeEach(async ({ page }) => {
6+
await page.goto('http://localhost:4567');
7+
expect(await boot(page)).toBe(true);
8+
});
9+
10+
test('clones ScelarOrg/Nodepod and checks out files', async ({ page }) => {
11+
const result = await gitClone(page, 'https://github.com/ScelarOrg/Nodepod');
12+
expect(result.exitCode).toBe(0);
13+
14+
const files = await listFiles(page, '/workspace/Nodepod');
15+
expect(files).toContain('package.json');
16+
expect(files).toContain('README.md');
17+
18+
const pkgContent = await readFile(page, '/workspace/Nodepod/package.json');
19+
expect(pkgContent).toBeTruthy();
20+
const pkg = JSON.parse(pkgContent);
21+
expect(pkg.name).toBe('@scelar/nodepod');
22+
});
23+
24+
test('README.md contains nodepod description', async ({ page }) => {
25+
await gitClone(page, 'https://github.com/ScelarOrg/Nodepod');
26+
const readme = await readFile(page, '/workspace/Nodepod/README.md');
27+
expect(readme).toBeTruthy();
28+
expect(readme.toLowerCase()).toContain('nodepod');
29+
});
30+
31+
test('.git directory is created', async ({ page }) => {
32+
await gitClone(page, 'https://github.com/ScelarOrg/Nodepod');
33+
expect(await fileExists(page, '/workspace/Nodepod/.git')).toBe(true);
34+
expect(await fileExists(page, '/workspace/Nodepod/.git/HEAD')).toBe(true);
35+
});
36+
37+
test('clone non-existent repo returns error', async ({ page }) => {
38+
const result = await gitClone(page, 'https://github.com/xyd-js/this-repo-does-not-exist-12345');
39+
expect(result.exitCode).not.toBe(0);
40+
});
41+
});

0 commit comments

Comments
 (0)