Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,9 @@
"Bash(tree:*)",
"Bash(vim:*)",
"Bash(test:*)",
"Bash(rm:./.notes/scratch/*)",
"Bash(mv:./.notes/*)",
"Bash(cat:./.notes/*)",
"Bash(rm ./.notes/scratch/:*)",
"Bash(mv ./.notes/:*)",
"Bash(cat ./.notes/:*)",
"Bash(./.claude/skills/_shared/notes/search-notes.sh:*)",
"Bash(./.claude/skills/_shared/notes/list-titles.sh:*)",
"Bash(./.claude/skills/_shared/notes/list-topics.sh:*)",
Expand Down
13 changes: 13 additions & 0 deletions .github/actions/setup/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@ description: 'Common setup steps for pgflow CI workflow (run after checkout)'
runs:
using: 'composite'
steps:
# Configure Docker daemon to allow containers to set high ulimits
# This prevents "ulimit: open files: cannot modify limit: Operation not permitted" errors
# when Supabase containers (pooler, realtime) try to set RLIMIT_NOFILE to 100000.
# The error occurs because GitHub Actions runners have restrictive ulimit defaults
# that prevent containers from increasing their file descriptor limits.
# See: https://github.com/orgs/supabase/discussions/18228
- name: Configure Docker ulimits for Supabase
shell: bash
run: |
sudo mkdir -p /etc/docker
echo '{"default-ulimits":{"nofile":{"Name":"nofile","Hard":100000,"Soft":100000}}}' | sudo tee /etc/docker/daemon.json
sudo systemctl restart docker

- uses: pnpm/action-setup@v4
with:
version: '10.20.0'
Expand Down
3 changes: 2 additions & 1 deletion apps/demo/supabase/functions/article_flow_worker/deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,10 @@ async function summarizeWithGroq(content: string, apiKey: string) {
},
{
role: 'user',
content: `Summarize this article in 2-3 sentences and determine its sentiment:\n\n${content.slice(0, 4000)}`
content: `Summarize this article in 2-3 sentences and determine its sentiment:\n\n${content.slice(
0,
4000
)}`
}
],
temperature: 0.7,
Expand Down Expand Up @@ -84,7 +87,10 @@ async function summarizeWithOpenAI(content: string, apiKey: string) {
},
{
role: 'user',
content: `Summarize this article in 2-3 sentences and determine its sentiment:\n\n${content.slice(0, 4000)}`
content: `Summarize this article in 2-3 sentences and determine its sentiment:\n\n${content.slice(
0,
4000
)}`
}
],
temperature: 0.7,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { assert } from 'https://deno.land/std@0.208.0/assert/mod.ts';
import { stub } from 'https://deno.land/std@0.208.0/testing/mock.ts';
import { fetchArticle } from '../tasks/fetch-article.ts';
import { load } from 'https://deno.land/std@0.208.0/dotenv/mod.ts';

Expand All @@ -7,47 +8,113 @@ await load({ envPath: '../.env', export: true }).catch(() => {
console.log('No .env file found, using environment variables');
});

Deno.test('fetchArticle - fetches real article from Hacker News', async () => {
// Use a stable HN article URL that should always exist
const url = 'https://news.ycombinator.com/item?id=35629516';
const mockJinaResponse = `# Mock Article Title
const result = await fetchArticle(url);
This is mock content with enough text to pass validation tests.
It has multiple lines and is over 100 characters long.
`;

// Verify we got a result with both content and title
assert(result.content, 'Should have content');
assert(result.title, 'Should have title');
assert(result.content.length > 100, 'Content should be substantial');
assert(result.title !== 'Untitled Article', 'Should extract a real title');
function stubFetch(response: { status: number; statusText: string; body?: string }) {
return stub(globalThis, 'fetch', () =>
Promise.resolve(
new Response(response.body || '', {
status: response.status,
statusText: response.statusText
})
)
);
}

console.log(`✓ Fetched article: "${result.title}" (${result.content.length} chars)`);
});
function stubFetchError(error: Error) {
return stub(globalThis, 'fetch', () => Promise.reject(error));
}

Deno.test('fetchArticle - fetches real article from TechCrunch', async () => {
// Use TechCrunch homepage which should always work
const url = 'https://techcrunch.com';
// Mocked tests (always run)
Deno.test('fetchArticle - fetches article with mocked fetch', async () => {
const fetchStub = stubFetch({ status: 200, statusText: 'OK', body: mockJinaResponse });

const result = await fetchArticle(url);
try {
const url = 'https://example.com/article';
const result = await fetchArticle(url);

assert(result.content, 'Should have content');
assert(result.title, 'Should have title');
assert(result.content.length > 100, 'Content should be substantial');
assert(result.content, 'Should have content');
assert(result.title, 'Should have title');
assert(result.content.length > 100, 'Content should be substantial');
assert(result.title === 'Mock Article Title', 'Should extract title from mock response');

console.log(`✓ Fetched article: "${result.title}" (${result.content.length} chars)`);
console.log('✓ Mock fetch works');
} finally {
fetchStub.restore();
}
});

Deno.test('fetchArticle - handles non-OK response with mocked fetch', async () => {
const fetchStub = stubFetch({
status: 451,
statusText: 'Unavailable For Legal Reasons',
body: 'Unavailable'
});

try {
const url = 'https://example.com/blocked-article';
await fetchArticle(url);
assert(false, 'Should have thrown an error');
} catch (error) {
assert(error instanceof Error);
assert(error.message.includes('Failed to fetch'));
assert(error.message.includes('451'));
console.log('✓ Properly handles non-OK responses');
} finally {
fetchStub.restore();
}
});

Deno.test({
name: 'fetchArticle - handles non-existent URL gracefully',
sanitizeResources: false, // Disable resource leak check for this test
name: 'fetchArticle - handles network errors with mocked fetch',
sanitizeResources: false,
fn: async () => {
const url = 'https://this-domain-definitely-does-not-exist-12345.com/article';
const fetchStub = stubFetchError(new TypeError('Network error'));

try {
const url = 'https://example.com/article';
await fetchArticle(url);
assert(false, 'Should have thrown an error');
} catch (error) {
assert(error instanceof Error);
assert(error.message.includes('Failed to fetch'));
console.log('✓ Properly handles fetch errors');
console.log('✓ Properly handles network errors');
} finally {
fetchStub.restore();
}
}
});

// Real HTTP tests (only run when USE_REAL_HTTP=true)
if (Deno.env.get('USE_REAL_HTTP') === 'true') {
Deno.test('fetchArticle - fetches real article from Hacker News', async () => {
const url = 'https://news.ycombinator.com/item?id=35629516';

const result = await fetchArticle(url);

assert(result.content, 'Should have content');
assert(result.title, 'Should have title');
assert(result.content.length > 100, 'Content should be substantial');
assert(result.title !== 'Untitled Article', 'Should extract a real title');

console.log(`✓ Fetched real article: "${result.title}" (${result.content.length} chars)`);
});

Deno.test('fetchArticle - fetches real article from TechCrunch', async () => {
const url = 'https://techcrunch.com';

const result = await fetchArticle(url);

assert(result.content, 'Should have content');
assert(result.title, 'Should have title');
assert(result.content.length > 100, 'Content should be substantial');

console.log(`✓ Fetched real article: "${result.title}" (${result.content.length} chars)`);
});
} else {
console.log('ℹ Skipping real HTTP tests (set USE_REAL_HTTP=true to run them)');
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"eslint-config-prettier": "10.1.5",
"jiti": "2.4.2",
"jsdom": "~22.1.0",
"jsonc-eslint-parser": "^2.4.1",
"jsr": "^0.13.4",
"netlify-cli": "^22.1.3",
"nx": "21.2.1",
Expand Down
19 changes: 10 additions & 9 deletions pkgs/client/__tests__/PgflowClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
createRunResponse,
mockRpcCall,
emitBroadcastEvent,
createSyncSchedule,
} from './helpers/test-utils';
import {
createRunCompletedEvent,
Expand All @@ -24,7 +25,7 @@ describe('PgflowClient', () => {

test('initializes correctly', () => {
const { client } = createMockClient();
const pgflowClient = new PgflowClient(client);
const pgflowClient = new PgflowClient(client, { realtimeStabilizationDelayMs: 0, schedule: createSyncSchedule() });

expect(pgflowClient).toBeDefined();
});
Expand All @@ -40,7 +41,7 @@ describe('PgflowClient', () => {
);
mockRpcCall(mocks, response);

const pgflowClient = new PgflowClient(client);
const pgflowClient = new PgflowClient(client, { realtimeStabilizationDelayMs: 0, schedule: createSyncSchedule() });
const run = await pgflowClient.startFlow(FLOW_SLUG, input);

// Check RPC call
Expand All @@ -66,7 +67,7 @@ describe('PgflowClient', () => {
const error = new Error('RPC error');
mockRpcCall(mocks, { data: null, error });

const pgflowClient = new PgflowClient(client);
const pgflowClient = new PgflowClient(client, { realtimeStabilizationDelayMs: 0, schedule: createSyncSchedule() });

// The startFlow call should reject with the error
await expect(
Expand All @@ -84,7 +85,7 @@ describe('PgflowClient', () => {
);
mockRpcCall(mocks, response);

const pgflowClient = new PgflowClient(client);
const pgflowClient = new PgflowClient(client, { realtimeStabilizationDelayMs: 0, schedule: createSyncSchedule() });

// First call should fetch from DB
const run1 = await pgflowClient.getRun(RUN_ID);
Expand All @@ -105,7 +106,7 @@ describe('PgflowClient', () => {
// Mock the RPC call to return no run
mockRpcCall(mocks, { data: { run: null, steps: [] }, error: null });

const pgflowClient = new PgflowClient(client);
const pgflowClient = new PgflowClient(client, { realtimeStabilizationDelayMs: 0, schedule: createSyncSchedule() });

const result = await pgflowClient.getRun('nonexistent-id');

Expand All @@ -120,7 +121,7 @@ describe('PgflowClient', () => {
mockRpcCall(mocks, response);

// Create test client
const pgflowClient = new PgflowClient(client);
const pgflowClient = new PgflowClient(client, { realtimeStabilizationDelayMs: 0, schedule: createSyncSchedule() });

// Get a run to create an instance
const run = await pgflowClient.getRun(RUN_ID);
Expand Down Expand Up @@ -154,7 +155,7 @@ describe('PgflowClient', () => {
const response = createRunResponse({ run_id: RUN_ID, input });
mockRpcCall(mocks, response);

const pgflowClient = new PgflowClient(client);
const pgflowClient = new PgflowClient(client, { realtimeStabilizationDelayMs: 0, schedule: createSyncSchedule() });

// Start a flow to create a run instance
const run = await pgflowClient.startFlow(FLOW_SLUG, input);
Expand Down Expand Up @@ -189,7 +190,7 @@ describe('PgflowClient', () => {
mockRpcCall(mocks, response1);
mockRpcCall(mocks, response2);

const pgflowClient = new PgflowClient(client);
const pgflowClient = new PgflowClient(client, { realtimeStabilizationDelayMs: 0, schedule: createSyncSchedule() });

// Get two different runs
await pgflowClient.getRun('1');
Expand All @@ -215,7 +216,7 @@ describe('PgflowClient', () => {
const response = createRunResponse({ run_id: RUN_ID, input }, []);
mockRpcCall(mocks, response);

const pgflowClient = new PgflowClient(client);
const pgflowClient = new PgflowClient(client, { realtimeStabilizationDelayMs: 0, schedule: createSyncSchedule() });

// Start a flow
const run = await pgflowClient.startFlow(FLOW_SLUG, input);
Expand Down
20 changes: 12 additions & 8 deletions pkgs/client/__tests__/SupabaseBroadcastAdapter.simple.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,15 @@ describe('SupabaseBroadcastAdapter - Simple Tests', () => {
*/
test('subscribes to a run and configures channel correctly', async () => {
const { client, mocks } = createMockClient();
const adapter = new SupabaseBroadcastAdapter(client);
const adapter = new SupabaseBroadcastAdapter(client, { stabilizationDelayMs: 0 });

// Setup realistic channel subscription
mockChannelSubscription(mocks);

// Subscribe to run
await adapter.subscribeToRun(RUN_ID);
// Subscribe to run (need to advance timers for setTimeout(..., 0))
const subscribePromise = adapter.subscribeToRun(RUN_ID);
await vi.runAllTimersAsync();
await subscribePromise;

// Check channel was created with correct name
expect(client.channel).toHaveBeenCalledWith(`pgflow:run:${RUN_ID}`);
Expand Down Expand Up @@ -71,7 +73,7 @@ describe('SupabaseBroadcastAdapter - Simple Tests', () => {
*/
test('properly routes events to registered callbacks', () => {
const { client, mocks } = createMockClient();
const adapter = new SupabaseBroadcastAdapter(client);
const adapter = new SupabaseBroadcastAdapter(client, { stabilizationDelayMs: 0 });

// Set up event listeners
const runSpy = vi.fn();
Expand Down Expand Up @@ -112,7 +114,7 @@ describe('SupabaseBroadcastAdapter - Simple Tests', () => {
error: null,
});

const adapter = new SupabaseBroadcastAdapter(client);
const adapter = new SupabaseBroadcastAdapter(client, { stabilizationDelayMs: 0 });

// Call method directly
const result = await adapter.getRunWithStates(RUN_ID);
Expand All @@ -135,13 +137,15 @@ describe('SupabaseBroadcastAdapter - Simple Tests', () => {
*/
test('properly cleans up on unsubscribe', async () => {
const { client, mocks } = createMockClient();
const adapter = new SupabaseBroadcastAdapter(client);
const adapter = new SupabaseBroadcastAdapter(client, { stabilizationDelayMs: 0 });

// Setup realistic channel subscription
mockChannelSubscription(mocks);

// Subscribe then unsubscribe
await adapter.subscribeToRun(RUN_ID);
// Subscribe then unsubscribe (need to advance timers for setTimeout(..., 0))
const subscribePromise = adapter.subscribeToRun(RUN_ID);
await vi.runAllTimersAsync();
await subscribePromise;
adapter.unsubscribe(RUN_ID);

// Check channel was unsubscribed
Expand Down
Loading