Skip to content

Commit 2c8e42e

Browse files
committed
chore: failfast client tests if connectivity issues
1 parent 216c245 commit 2c8e42e

File tree

6 files changed

+375
-1
lines changed

6 files changed

+375
-1
lines changed

.claude/skills/restack/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
name: restack
33
description: Use when user asks to "restack", "resolve conflicts", "gt restack", or mentions restack conflicts. Guides through gt restack conflicts with intelligent diagnostics and resolution.
4-
allowed-tools: Bash(gt restack:*), Bash(gt continue:*), Bash(git show:*), Bash(git diff:*), Bash(git log:*), Bash(git ls-tree:*), Bash(pnpm install:*), Bash(git status:*), Bash(gt log:*), Bash(rm pnpm-lock.yaml), Bash(gt add -A:*),
4+
allowed-tools: Bash(gt restack:*), Bash(gt continue:*), Bash(git show:*), Bash(git diff:*), Bash(git log:*), Bash(git ls-tree:*), Bash(pnpm install:*), Bash(git status:*), Bash(gt log:*), Bash(rm pnpm-lock.yaml), Bash(gt add -A:*), Bash(git checkout --theirs:*), Bash(git checkout --ours:*)
55
---
66

77
<critical>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { describe, it, expect } from 'vitest';
2+
3+
describe('Dummy', () => {
4+
it('passes', () => {
5+
expect(true).toBe(true);
6+
});
7+
});
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
/**
2+
* Connectivity smoke test for Supabase services required by pgflow client.
3+
* This test validates all the connectivity patterns used by the client:
4+
* 1. Realtime channel subscription and message broadcasting
5+
* 2. Database queries (from() method)
6+
* 3. RPC calls
7+
*
8+
* Run this test first in CI to quickly identify connectivity issues
9+
* before running the full test suite.
10+
*/
11+
12+
import { describe, it, expect, beforeAll } from 'vitest';
13+
import { createClient, SupabaseClient, RealtimeChannel } from '@supabase/supabase-js';
14+
import { createTestSupabaseClient } from '../helpers/setup.js';
15+
import { withPgNoTransaction } from '../helpers/db.js';
16+
import { grantMinimalPgflowPermissions } from '../helpers/permissions.js';
17+
18+
describe('Supabase Connectivity Check', () => {
19+
let supabase: SupabaseClient;
20+
21+
beforeAll(async () => {
22+
// Use the same setup as other integration tests - service_role key
23+
supabase = createTestSupabaseClient();
24+
25+
console.log('🔍 Supabase connectivity check starting...');
26+
console.log(` URL: http://localhost:50521`);
27+
console.log(` Using: service_role key (same as other integration tests)`);
28+
29+
// Grant permissions like other integration tests do
30+
await withPgNoTransaction(async (sql) => {
31+
await grantMinimalPgflowPermissions(sql);
32+
})();
33+
});
34+
35+
it('has valid Supabase configuration', () => {
36+
const url = process.env.SUPABASE_URL || 'http://localhost:50521';
37+
const key = process.env.SUPABASE_ANON_KEY ||
38+
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0';
39+
40+
expect(url).toBeTruthy();
41+
expect(key).toBeTruthy();
42+
expect(url).toMatch(/^https?:\/\//);
43+
expect(key).toMatch(/^eyJ/); // JWT tokens start with eyJ
44+
});
45+
46+
it('can establish Realtime channel subscription', async () => {
47+
const channelName = `connectivity-test-${Date.now()}`;
48+
let channel: RealtimeChannel | null = null;
49+
50+
try {
51+
channel = supabase.channel(channelName);
52+
53+
// Track connection status
54+
const statusPromise = new Promise<string>((resolve, reject) => {
55+
const timeout = setTimeout(() => {
56+
const currentStatus = channel?.state || 'unknown';
57+
reject(new Error(
58+
`Realtime subscription timeout after 10s. ` +
59+
`Final status: ${currentStatus}. ` +
60+
`This suggests Supabase Realtime is not accessible in CI.`
61+
));
62+
}, 10000);
63+
64+
channel!.subscribe((status) => {
65+
console.log(` Channel status: ${status}`);
66+
67+
if (status === 'SUBSCRIBED') {
68+
clearTimeout(timeout);
69+
resolve(status);
70+
} else if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') {
71+
clearTimeout(timeout);
72+
reject(new Error(
73+
`Channel subscription failed with status: ${status}. ` +
74+
`This indicates Supabase Realtime connection issues.`
75+
));
76+
}
77+
});
78+
});
79+
80+
const status = await statusPromise;
81+
expect(status).toBe('SUBSCRIBED');
82+
console.log(' ✓ Realtime subscription established');
83+
} finally {
84+
if (channel) {
85+
await supabase.removeChannel(channel);
86+
}
87+
}
88+
}, 15000);
89+
90+
it('can send and receive broadcast messages', async () => {
91+
const channelName = `broadcast-test-${Date.now()}`;
92+
const testMessage = { test: 'connectivity', timestamp: Date.now() };
93+
let channel: RealtimeChannel | null = null;
94+
95+
try {
96+
channel = supabase.channel(channelName);
97+
98+
// Set up message reception
99+
const messagePromise = new Promise<any>((resolve, reject) => {
100+
const timeout = setTimeout(() => {
101+
reject(new Error(
102+
'Broadcast message timeout after 10s. ' +
103+
'Channel subscribed but messages not being received.'
104+
));
105+
}, 10000);
106+
107+
// Listen for broadcast messages (pgflow client pattern)
108+
channel!.on('broadcast', { event: '*' }, (payload) => {
109+
console.log(' Received broadcast:', payload);
110+
if (payload.event === 'test-event') {
111+
clearTimeout(timeout);
112+
resolve(payload.payload);
113+
}
114+
});
115+
116+
// Subscribe and send message
117+
channel!.subscribe(async (status) => {
118+
if (status === 'SUBSCRIBED') {
119+
console.log(' Sending test broadcast...');
120+
// Send using the pattern from SupabaseBroadcastAdapter
121+
await channel!.send({
122+
type: 'broadcast',
123+
event: 'test-event',
124+
payload: testMessage
125+
});
126+
}
127+
});
128+
});
129+
130+
const received = await messagePromise;
131+
expect(received).toEqual(testMessage);
132+
console.log(' ✓ Broadcast messages working');
133+
} finally {
134+
if (channel) {
135+
await supabase.removeChannel(channel);
136+
}
137+
}
138+
}, 15000);
139+
140+
it('can query pgflow database tables', async () => {
141+
// Test the same queries used by SupabaseBroadcastAdapter
142+
const { data: flowsData, error: flowsError } = await supabase
143+
.schema('pgflow')
144+
.from('flows')
145+
.select('flow_slug')
146+
.limit(1);
147+
148+
if (flowsError) {
149+
throw new Error(
150+
`Failed to query pgflow.flows table: ${flowsError.message}. ` +
151+
`This suggests database connectivity or schema issues.`
152+
);
153+
}
154+
155+
// We don't need any flows to exist, just that the query works
156+
expect(flowsError).toBeNull();
157+
console.log(' ✓ Database query to pgflow.flows working');
158+
159+
// Test steps table query
160+
const { data: stepsData, error: stepsError } = await supabase
161+
.schema('pgflow')
162+
.from('steps')
163+
.select('step_slug')
164+
.limit(1);
165+
166+
if (stepsError) {
167+
throw new Error(
168+
`Failed to query pgflow.steps table: ${stepsError.message}. ` +
169+
`This suggests database schema issues.`
170+
);
171+
}
172+
173+
expect(stepsError).toBeNull();
174+
console.log(' ✓ Database query to pgflow.steps working');
175+
});
176+
177+
it('can call pgflow RPC functions', async () => {
178+
// Test calling the RPC used by PgflowClient (with a fake run_id that won't exist)
179+
const testRunId = '00000000-0000-0000-0000-000000000000';
180+
181+
const { data, error } = await supabase
182+
.schema('pgflow')
183+
.rpc('get_run_with_states', { run_id: testRunId });
184+
185+
// We expect no data for a fake run_id, but the RPC should execute without error
186+
if (error && !error.message.includes('not found')) {
187+
throw new Error(
188+
`Failed to call pgflow.get_run_with_states RPC: ${error.message}. ` +
189+
`This suggests RPC connectivity or permission issues.`
190+
);
191+
}
192+
193+
// The RPC should return null/empty for non-existent run, not an error
194+
expect(error).toBeNull();
195+
console.log(' ✓ RPC call to pgflow.get_run_with_states working');
196+
});
197+
198+
it('validates full PgflowClient connection pattern', async () => {
199+
// This test simulates the exact pattern PgflowClient uses:
200+
// 1. Create channel with specific naming pattern
201+
// 2. Subscribe to channel
202+
// 3. Listen for events with wildcard pattern
203+
204+
const runId = `test-${Date.now()}`;
205+
const channelName = `pgflow:run:${runId}`;
206+
let channel: RealtimeChannel | null = null;
207+
208+
try {
209+
console.log(` Testing PgflowClient pattern with channel: ${channelName}`);
210+
channel = supabase.channel(channelName);
211+
212+
const eventReceived = new Promise<boolean>((resolve, reject) => {
213+
const timeout = setTimeout(() => {
214+
reject(new Error(
215+
`PgflowClient pattern timeout. Channel: ${channelName}, ` +
216+
`Status: ${channel?.state}. ` +
217+
`This is the exact pattern failing in the test suite.`
218+
));
219+
}, 10000);
220+
221+
// Use the exact event pattern from SupabaseBroadcastAdapter
222+
channel!.on('broadcast', { event: '*' }, (payload) => {
223+
clearTimeout(timeout);
224+
resolve(true);
225+
});
226+
227+
channel!.subscribe(async (status) => {
228+
console.log(` PgflowClient channel status: ${status}`);
229+
if (status === 'SUBSCRIBED') {
230+
// Send a test event matching pgflow patterns
231+
await channel!.send({
232+
type: 'broadcast',
233+
event: 'run:started',
234+
payload: { run_id: runId, status: 'Started' }
235+
});
236+
} else if (status === 'TIMED_OUT' || status === 'CHANNEL_ERROR') {
237+
clearTimeout(timeout);
238+
reject(new Error(`Channel failed with status: ${status}`));
239+
}
240+
});
241+
});
242+
243+
await expect(eventReceived).resolves.toBe(true);
244+
console.log(' ✓ PgflowClient connection pattern working');
245+
} finally {
246+
if (channel) {
247+
await supabase.removeChannel(channel);
248+
}
249+
}
250+
}, 15000);
251+
252+
// Summary test that provides actionable feedback
253+
it('provides connectivity summary', () => {
254+
console.log('\n✅ All Supabase connectivity checks passed!');
255+
console.log(' The client can successfully:');
256+
console.log(' - Subscribe to Realtime channels');
257+
console.log(' - Send and receive broadcast messages');
258+
console.log(' - Query database tables');
259+
console.log(' - Call RPC functions');
260+
console.log(' - Use PgflowClient connection patterns');
261+
});
262+
});

pkgs/client/test-stability.sh

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
#!/bin/bash
2+
3+
# Script to test channel stabilization delay reliability
4+
# Usage: ./test-stability.sh <number_of_iterations> [delay_ms]
5+
6+
iterations=${1:-10}
7+
delay_ms=${2:-"current"}
8+
9+
if [ "$delay_ms" != "current" ]; then
10+
echo "Note: To change delay, you need to manually edit vitest.global-setup.ts"
11+
echo "This script will test with the current delay setting"
12+
fi
13+
14+
echo "Testing channel stabilization with $iterations iterations"
15+
echo "Current delay: Check vitest.global-setup.ts for actual value"
16+
echo "================================================"
17+
18+
success_count=0
19+
fail_count=0
20+
21+
for i in $(seq 1 $iterations); do
22+
echo -n "Run $i/$iterations: "
23+
24+
# Run test and capture output
25+
output=$(pnpm vitest run __tests__/dummy.test.ts 2>&1)
26+
27+
# Check if test passed
28+
if echo "$output" | grep -q "Test Files 1 passed"; then
29+
echo "✓ PASS"
30+
success_count=$((success_count + 1))
31+
else
32+
echo "✗ FAIL"
33+
fail_count=$((fail_count + 1))
34+
35+
# Show error if failed
36+
if echo "$output" | grep -q "Supabase check failed"; then
37+
error_msg=$(echo "$output" | grep "Supabase check failed" | head -1)
38+
echo " Error: $error_msg"
39+
fi
40+
fi
41+
done
42+
43+
echo "================================================"
44+
echo "Results:"
45+
echo " Success: $success_count/$iterations ($(( success_count * 100 / iterations ))%)"
46+
echo " Failed: $fail_count/$iterations ($(( fail_count * 100 / iterations ))%)"
47+
48+
# Exit with non-zero if any failures
49+
if [ $fail_count -gt 0 ]; then
50+
exit 1
51+
fi

pkgs/client/vite.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export default defineConfig({
5151
'__tests__/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'
5252
],
5353
setupFiles: ['__tests__/setup.ts'],
54+
globalSetup: './vitest.global-setup.ts',
5455
reporters: ['default'],
5556
coverage: {
5657
reportsDirectory: '../../coverage/pkgs/client',

pkgs/client/vitest.global-setup.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { createClient } from '@supabase/supabase-js';
2+
import postgres from 'postgres';
3+
import { createHash } from 'crypto';
4+
5+
export async function setup() {
6+
// Create a hash-based channel name with only alphanumeric characters
7+
const timestamp = Date.now().toString();
8+
const random = Math.random().toString();
9+
const hash = createHash('sha1').update(timestamp + random).digest('hex');
10+
const channelName = `setup${hash.substring(0, 16)}`; // Use first 16 chars of hash
11+
console.log(`[GLOBAL SETUP] Using random channel: ${channelName}`);
12+
13+
const supabase = createClient('http://localhost:50521', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU');
14+
const sql = postgres('postgresql://postgres:postgres@localhost:50522/postgres', { prepare: false, onnotice: () => {} });
15+
const channel = supabase.channel(channelName);
16+
const events: any[] = [];
17+
18+
try {
19+
await sql`SELECT pgflow_tests.create_realtime_partition()`;
20+
channel.on('broadcast', { event: '*' }, (p) => events.push(p));
21+
22+
await new Promise<void>((ok, fail) => {
23+
const t = setTimeout(() => fail(new Error('timeout')), 10000);
24+
channel.subscribe((s) => {
25+
if (s === 'SUBSCRIBED') { clearTimeout(t); ok(); }
26+
if (s === 'TIMED_OUT' || s === 'CHANNEL_ERROR') { clearTimeout(t); fail(new Error(s)); }
27+
});
28+
});
29+
30+
// Add stabilization delay for cold channels to fully establish routing
31+
console.log('[GLOBAL SETUP] Channel subscribed, waiting 75ms for stabilization...');
32+
await new Promise(resolve => setTimeout(resolve, 75));
33+
34+
await sql`SELECT realtime.send('{}', 'e', ${channelName}, false)`;
35+
36+
const start = Date.now();
37+
while (events.length === 0 && Date.now() - start < 10000) {
38+
await new Promise((ok) => setTimeout(ok, 100));
39+
}
40+
41+
if (events.length === 0) throw new Error('realtime.send() failed');
42+
} catch (e) {
43+
console.error('\n❌ Supabase check failed:', e instanceof Error ? e.message : e, '\n');
44+
process.exit(1);
45+
} finally {
46+
await supabase.removeChannel(channel);
47+
await sql.end();
48+
}
49+
}
50+
51+
export async function teardown() {
52+
// Nothing to clean up globally
53+
}

0 commit comments

Comments
 (0)