Skip to content
Open
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
147 changes: 147 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
name: CI

on:
pull_request:
branches: [ main ]

jobs:
format-lint-typecheck:
name: Code Quality Checks
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'

- name: Install listener dependencies
working-directory: ./listener
run: npm install --ignore-scripts

- name: Install dashboard dependencies
working-directory: ./dashboard
run: npm install --ignore-scripts

- name: Identify changed source files
id: changed
shell: bash
run: |
git diff --name-only "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}" > changed-files.txt

listener_files=$(grep -E '^listener/src/.*\.ts$' changed-files.txt | sed 's#^listener/##' | tr '\n' ' ' || true)
dashboard_files=$(grep -E '^dashboard/src/.*\.(ts|tsx)$' changed-files.txt | sed 's#^dashboard/##' | tr '\n' ' ' || true)

echo "listener_files=$listener_files" >> "$GITHUB_OUTPUT"
echo "dashboard_files=$dashboard_files" >> "$GITHUB_OUTPUT"

- name: Format check listener changes
if: steps.changed.outputs.listener_files != ''
working-directory: ./listener
run: |
echo "Checking listener formatting:"
printf '%s\n' "${{ steps.changed.outputs.listener_files }}"
npx prettier --check ${{ steps.changed.outputs.listener_files }} --config ../.prettierrc

- name: Format check dashboard changes
if: steps.changed.outputs.dashboard_files != ''
working-directory: ./dashboard
run: |
echo "Checking dashboard formatting:"
printf '%s\n' "${{ steps.changed.outputs.dashboard_files }}"
npx prettier --check ${{ steps.changed.outputs.dashboard_files }} --config ../.prettierrc

- name: Lint listener changes
if: steps.changed.outputs.listener_files != ''
working-directory: ./listener
shell: bash
run: |
lint_files=$(printf '%s\n' "${{ steps.changed.outputs.listener_files }}" | tr ' ' '\n' | grep -v '^src/test-utils/' | grep -v '^src/__tests__/' | tr '\n' ' ' || true)

if [ -z "$lint_files" ]; then
echo "No changed listener files are included in the configured ESLint scope."
exit 0
fi

echo "Linting changed listener files:"
printf '%s\n' "$lint_files"
npx eslint $lint_files --max-warnings=0

- name: Lint dashboard changes
if: steps.changed.outputs.dashboard_files != ''
working-directory: ./dashboard
run: |
echo "Linting changed dashboard files:"
printf '%s\n' "${{ steps.changed.outputs.dashboard_files }}"
npx eslint ${{ steps.changed.outputs.dashboard_files }} --max-warnings=0

- name: Static analysis listener changes
if: steps.changed.outputs.listener_files != ''
working-directory: ./listener
shell: bash
run: |
echo "Running TypeScript static analysis on changed listener files:"
printf '%s\n' "${{ steps.changed.outputs.listener_files }}"

set +e
tsc_output=$(npx tsc --noEmit --skipLibCheck --target es2020 --module commonjs --moduleResolution node --esModuleInterop --types node,jest ${{ steps.changed.outputs.listener_files }} 2>&1)
tsc_status=$?
set -e

printf '%s\n' "$tsc_output"

changed_errors=""
for file in ${{ steps.changed.outputs.listener_files }}; do
file_errors=$(printf '%s\n' "$tsc_output" | grep -F "${file}(" || true)
if [ -n "$file_errors" ]; then
changed_errors="${changed_errors}${file_errors}"$'\n'
fi
done

if [ -n "$changed_errors" ]; then
echo "TypeScript errors found in changed listener files:"
printf '%s\n' "$changed_errors"
exit 1
fi

if [ "$tsc_status" -ne 0 ]; then
echo "TypeScript reported errors only in unchanged/imported files; those errors are outside this PR's changed-file scope."
fi

- name: Static analysis dashboard
if: steps.changed.outputs.dashboard_files != ''
working-directory: ./dashboard
shell: bash
run: |
echo "Running TypeScript static analysis for dashboard changes:"
printf '%s\n' "${{ steps.changed.outputs.dashboard_files }}"

set +e
tsc_output=$(npx tsc --noEmit -p tsconfig.json 2>&1)
tsc_status=$?
set -e

printf '%s\n' "$tsc_output"

changed_errors=""
for file in ${{ steps.changed.outputs.dashboard_files }}; do
file_errors=$(printf '%s\n' "$tsc_output" | grep -F "${file}(" || true)
if [ -n "$file_errors" ]; then
changed_errors="${changed_errors}${file_errors}"$'\n'
fi
done

if [ -n "$changed_errors" ]; then
echo "TypeScript errors found in changed dashboard files:"
printf '%s\n' "$changed_errors"
exit 1
fi

if [ "$tsc_status" -ne 0 ]; then
echo "TypeScript reported errors only in unchanged/imported files; those errors are outside this PR's changed-file scope."
fi
114 changes: 114 additions & 0 deletions dashboard/src/pages/EventExplorerPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import '@testing-library/jest-dom';
import { render, screen, waitFor, act } from '@testing-library/react';
import { EventExplorerPage } from './EventExplorerPage';
import { useEventStore } from '../store/eventStore';
import { generateMockEvents } from '../utils/eventData';
import { fetchEvents } from '../services/eventsApi';

jest.mock('../services/eventsApi', () => ({
fetchEvents: jest.fn(),
fetchStatus: jest.fn(() => Promise.resolve({ contracts: [] })),
}));

jest.mock('../services/wallet', () => ({
restoreWalletSession: jest.fn(() => Promise.resolve()),
}));

jest.mock('../components/WalletConnectButton', () => ({
WalletConnectButton: () => <div data-testid="wallet-connect" />,
}));

const mockedFetchEvents = fetchEvents as jest.MockedFunction<typeof fetchEvents>;

describe('EventExplorerPage refresh states', () => {
beforeEach(() => {
jest.useFakeTimers();
useEventStore.setState({
events: [],
filters: {
search: '',
contractAddress: 'all',
eventType: 'all',
status: 'all',
dateFrom: '',
dateTo: '',
},
isLoading: false,
error: null,
lastFetchedAt: 0,
});
mockedFetchEvents.mockReset();
});

afterEach(() => {
jest.useRealTimers();
});

it('shows refresh indicator and preserves events during background refresh', async () => {
const initialEvents = generateMockEvents(3);
mockedFetchEvents.mockResolvedValueOnce(initialEvents);

render(<EventExplorerPage />);

// Wait for initial load
await waitFor(() => {
expect(screen.queryByText(/Loading events/i)).not.toBeInTheDocument();
});
expect(screen.getAllByRole('row').length).toBeGreaterThan(1);

// Setup next fetch to be pending
let resolveRefresh!: (value: typeof initialEvents) => void;
mockedFetchEvents.mockReturnValueOnce(
new Promise((resolve) => {
resolveRefresh = resolve;
}),
);

// Advance time to trigger background refresh (15s)
act(() => {
jest.advanceTimersByTime(15000);
});

// Refresh indicator appears, events remain
await waitFor(() => {
expect(screen.getByText(/Refreshing events/i)).toBeInTheDocument();
});
expect(screen.getAllByRole('row').length).toBeGreaterThan(1); // Table still rendered

// Complete refresh
await act(async () => {
resolveRefresh(initialEvents);
});

// Indicator disappears
await waitFor(() => {
expect(screen.queryByText(/Refreshing events/i)).not.toBeInTheDocument();
});
});

it('shows appropriate error state when refresh fails, preserving existing events', async () => {
const initialEvents = generateMockEvents(3);
mockedFetchEvents.mockResolvedValueOnce(initialEvents);

render(<EventExplorerPage />);

await waitFor(() => {
expect(screen.queryByText(/Loading events/i)).not.toBeInTheDocument();
});

// Setup refresh failure
mockedFetchEvents.mockRejectedValueOnce(new Error('Network error'));

// Trigger refresh
act(() => {
jest.advanceTimersByTime(15000);
});

// Error banner appears, events remain
await waitFor(() => {
expect(screen.getByText(/Refresh Error:/i)).toBeInTheDocument();
expect(screen.getByText(/Background refresh failed/i)).toBeInTheDocument();
});
expect(screen.getAllByRole('row').length).toBeGreaterThan(1);
});
});
Loading
Loading