@juppytt/fws 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,109 @@
1
+ import { createApp } from '../../src/server/app.js';
2
+ import { resetStore } from '../../src/store/index.js';
3
+ import { generateConfigDir } from '../../src/config/rewrite-cache.js';
4
+ import { generateCACert, startMitmProxy } from '../../src/proxy/mitm.js';
5
+ import { execFile } from 'node:child_process';
6
+ import { mkdtemp, rm } from 'node:fs/promises';
7
+ import { tmpdir } from 'node:os';
8
+ import path from 'node:path';
9
+ import type { Server } from 'node:http';
10
+
11
+ export interface TestHarness {
12
+ port: number;
13
+ /** Direct HTTP fetch against mock server */
14
+ fetch: (urlPath: string, init?: RequestInit) => Promise<Response>;
15
+ /** Run gws command (uses discovery cache rewriting for regular commands) */
16
+ gws: (args: string) => Promise<{ stdout: string; stderr: string; exitCode: number }>;
17
+ /** Run gws command with MITM proxy (for helper commands like +triage) */
18
+ gwsProxy: (args: string) => Promise<{ stdout: string; stderr: string; exitCode: number }>;
19
+ cleanup: () => Promise<void>;
20
+ }
21
+
22
+ export async function createTestHarness(): Promise<TestHarness> {
23
+ resetStore();
24
+
25
+ const app = createApp();
26
+ const server: Server = await new Promise((resolve) => {
27
+ const s = app.listen(0, () => resolve(s));
28
+ });
29
+ const port = (server.address() as any).port as number;
30
+
31
+ const configDir = await mkdtemp(path.join(tmpdir(), 'fws-test-'));
32
+ await generateConfigDir(port, configDir);
33
+
34
+ // MITM proxy for helper commands
35
+ const dataDir = await mkdtemp(path.join(tmpdir(), 'fws-test-data-'));
36
+ const { caPath } = await generateCACert(dataDir);
37
+ const proxyServer = startMitmProxy(port, 0); // random port
38
+ const proxyPort = (proxyServer.address() as any).port as number;
39
+
40
+ const baseEnv = {
41
+ ...process.env,
42
+ GOOGLE_WORKSPACE_CLI_CONFIG_DIR: configDir,
43
+ GOOGLE_WORKSPACE_CLI_TOKEN: 'fake',
44
+ };
45
+
46
+ const proxyEnv = {
47
+ ...baseEnv,
48
+ HTTPS_PROXY: `http://localhost:${proxyPort}`,
49
+ SSL_CERT_FILE: caPath,
50
+ };
51
+
52
+ const gwsPath = process.env.GWS_PATH || 'gws';
53
+
54
+ function parseArgs(args: string): string[] {
55
+ // Split on whitespace but keep JSON objects and quoted strings as single args
56
+ const result: string[] = [];
57
+ let current = '';
58
+ let braceDepth = 0;
59
+ let inQuote: string | null = null;
60
+ for (const char of args) {
61
+ if (!inQuote && !braceDepth && (char === '"' || char === "'")) {
62
+ inQuote = char;
63
+ continue;
64
+ }
65
+ if (inQuote && char === inQuote) {
66
+ inQuote = null;
67
+ continue;
68
+ }
69
+ if (!inQuote) {
70
+ if (char === '{') braceDepth++;
71
+ if (char === '}') braceDepth--;
72
+ }
73
+ if (char === ' ' && braceDepth === 0 && !inQuote) {
74
+ if (current) result.push(current);
75
+ current = '';
76
+ } else {
77
+ current += char;
78
+ }
79
+ }
80
+ if (current) result.push(current);
81
+ return result;
82
+ }
83
+
84
+ function runGws(args: string, env: Record<string, string | undefined>): Promise<{ stdout: string; stderr: string; exitCode: number }> {
85
+ return new Promise((resolve) => {
86
+ execFile(gwsPath, parseArgs(args), { env, timeout: 10000 }, (err, stdout, stderr) => {
87
+ resolve({
88
+ stdout: stdout || '',
89
+ stderr: stderr || '',
90
+ exitCode: err ? (err as any).code ?? 1 : 0,
91
+ });
92
+ });
93
+ });
94
+ }
95
+
96
+ return {
97
+ port,
98
+ fetch: (urlPath: string, init?: RequestInit) =>
99
+ globalThis.fetch(`http://localhost:${port}${urlPath}`, init),
100
+ gws: (args: string) => runGws(args, baseEnv),
101
+ gwsProxy: (args: string) => runGws(args, proxyEnv),
102
+ cleanup: async () => {
103
+ server.close();
104
+ proxyServer.close();
105
+ await rm(configDir, { recursive: true, force: true });
106
+ await rm(dataDir, { recursive: true, force: true });
107
+ },
108
+ };
109
+ }
@@ -0,0 +1,80 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
+ import { createTestHarness, type TestHarness } from './helpers/harness.js';
3
+
4
+ describe('Snapshot', () => {
5
+ let h: TestHarness;
6
+
7
+ beforeAll(async () => {
8
+ h = await createTestHarness();
9
+ });
10
+
11
+ afterAll(async () => {
12
+ await h.cleanup();
13
+ });
14
+
15
+ it('save returns current state as JSON', async () => {
16
+ const res = await h.fetch('/__fws/snapshot/save', { method: 'POST' });
17
+ const data = await res.json();
18
+ expect(data.gmail).toBeDefined();
19
+ expect(data.calendar).toBeDefined();
20
+ expect(data.drive).toBeDefined();
21
+ });
22
+
23
+ it('full roundtrip: add data, snapshot, reset, verify empty, load, verify data', async () => {
24
+ // 1. Add some data
25
+ await h.fetch('/__fws/setup/gmail/message', {
26
+ method: 'POST',
27
+ headers: { 'Content-Type': 'application/json' },
28
+ body: JSON.stringify({ from: 'snap@example.com', subject: 'Snapshot Test', body: 'snapshot body' }),
29
+ });
30
+ await h.fetch('/__fws/setup/calendar/event', {
31
+ method: 'POST',
32
+ headers: { 'Content-Type': 'application/json' },
33
+ body: JSON.stringify({ summary: 'Snapshot Event', start: '2026-06-01T10:00:00Z', duration: '1h' }),
34
+ });
35
+ await h.fetch('/__fws/setup/drive/file', {
36
+ method: 'POST',
37
+ headers: { 'Content-Type': 'application/json' },
38
+ body: JSON.stringify({ name: 'snapshot-file.txt' }),
39
+ });
40
+
41
+ // 2. Snapshot
42
+ const snapRes = await h.fetch('/__fws/snapshot/save', { method: 'POST' });
43
+ const snapshot = await snapRes.json();
44
+ expect(Object.keys(snapshot.gmail.messages).length).toBeGreaterThan(0);
45
+
46
+ // 3. Reset
47
+ await h.fetch('/__fws/reset', { method: 'POST' });
48
+
49
+ // 4. Verify reset to seed (5 seed messages, 5 seed files — the extras we added are gone)
50
+ const msgsRes = await h.fetch('/gmail/v1/users/me/messages');
51
+ const msgs = await msgsRes.json();
52
+ expect(msgs.messages.length).toBe(5); // only seed messages
53
+
54
+ const filesRes = await h.fetch('/drive/v3/files');
55
+ const files = await filesRes.json();
56
+ expect(files.files.length).toBe(5); // only seed files
57
+
58
+ // 5. Load snapshot
59
+ await h.fetch('/__fws/snapshot/load', {
60
+ method: 'POST',
61
+ headers: { 'Content-Type': 'application/json' },
62
+ body: JSON.stringify(snapshot),
63
+ });
64
+
65
+ // 6. Verify data restored (seed + our extras)
66
+ const msgsRes2 = await h.fetch('/gmail/v1/users/me/messages');
67
+ const msgs2 = await msgsRes2.json();
68
+ expect(msgs2.messages.length).toBeGreaterThan(5); // seed + added
69
+
70
+ const filesRes2 = await h.fetch('/drive/v3/files');
71
+ const files2 = await filesRes2.json();
72
+ expect(files2.files.length).toBeGreaterThan(5); // seed + added
73
+
74
+ // Verify via gws
75
+ const { stdout, exitCode } = await h.gws('gmail users messages list --params {"userId":"me"}');
76
+ expect(exitCode).toBe(0);
77
+ const gwsMsgs = JSON.parse(stdout);
78
+ expect(gwsMsgs.messages.length).toBeGreaterThan(0);
79
+ });
80
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "Node16",
5
+ "moduleResolution": "Node16",
6
+ "outDir": "dist",
7
+ "rootDir": ".",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "declaration": true,
13
+ "sourceMap": true
14
+ },
15
+ "include": ["src/**/*", "bin/**/*"],
16
+ "exclude": ["node_modules", "dist", "test"]
17
+ }
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ testTimeout: 15000,
6
+ hookTimeout: 10000,
7
+ },
8
+ });