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
32 changes: 32 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,38 @@ permissions:
id-token: write

jobs:
e2e:
name: E2E
runs-on: ubuntu-latest
timeout-minutes: 30
concurrency:
group: ci-e2e-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.10
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 24
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache Rust
uses: Swatinem/rust-cache@v2
with:
workspaces: packages/eclipsa/compiler/rust
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Install Chromium
working-directory: e2e
run: bun x playwright install --with-deps chromium
- name: Run E2E suite
run: bun run test:e2e --log-order=stream

quality:
name: Quality
runs-on: ubuntu-latest
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/website.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ jobs:

- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-wasip1-threads

- name: Cache Rust
uses: Swatinem/rust-cache@v2
Expand All @@ -49,6 +51,13 @@ jobs:
- name: Build optimizer native artifacts
run: bun run --filter @eclipsa/optimizer build:native:dev

- name: Build playground browser compiler
working-directory: packages/optimizer
run: bun ./scripts/run-napi.ts build --platform --release --manifest-path ../eclipsa/compiler/rust/Cargo.toml --package-json-path ./package.json --target-dir ../eclipsa/compiler/rust/target --output-dir ./generated --js-package-name @eclipsa/optimizer --target wasm32-wasip1-threads

- name: Verify browser compiler artifact
run: node --input-type=module -e "import { readFile } from 'node:fs/promises'; await WebAssembly.compile(await readFile('packages/optimizer/generated/optimizer.wasm32-wasi.wasm'));"

- name: Build website
run: bun run --cwd docs build

Expand Down
10 changes: 7 additions & 3 deletions docs/app/docs/+page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { onVisible, useNavigate, type MetadataContext } from 'eclipsa'
import { Link, onVisible, useNavigate, type MetadataContext } from 'eclipsa'

export const metadata = ({ url }: MetadataContext) => ({
canonical: url.pathname,
Expand All @@ -8,7 +8,11 @@ export const metadata = ({ url }: MetadataContext) => ({
export default () => {
const nav = useNavigate()
onVisible(() => {
nav('/docs/getting-started/overview')
void nav('/docs/getting-started/overview', { replace: true })
})
return <div />
return (
<Link href="/docs/getting-started/overview" replace>
Open the documentation
</Link>
)
}
14 changes: 0 additions & 14 deletions docs/app/docs/[...slug]/+layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -204,16 +204,6 @@ const OnThisPageNav = (props: {

const OnThisPageLink = (props: { heading: TocHeading }) => {
const { heading } = props
const jumpToHeading = (slug: string) => {
if (typeof document === 'undefined') return
const target = document.getElementById(slug)
if (!(target instanceof HTMLElement)) return
target.scrollIntoView({
behavior: 'smooth',
block: 'start',
})
history.replaceState(null, '', `#${slug}`)
}

return (
<a
Expand All @@ -222,10 +212,6 @@ const OnThisPageLink = (props: { heading: TocHeading }) => {
heading.depth > 2 ? 'ml-4 text-[13px] text-[color:var(--docs-text-soft)]' : '',
)}
href={`#${heading.slug}`}
onClick={(event) => {
event.preventDefault()
jumpToHeading(heading.slug)
}}
>
{heading.text}
</a>
Expand Down
7 changes: 6 additions & 1 deletion e2e/app/hash-nav/+page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,17 @@ export default () => (
<h2>Hash Navigation</h2>
<p>Client-side fragment navigation should preserve native anchor scrolling.</p>
<p>
<Link href="#deep-dive">Jump to deep dive</Link>
<Link href="#deep-dive" prefetch="none">
Jump to deep dive
</Link>
</p>
<div style="height: 3000px;" />
<h3 id="deep-dive" data-testid="hash-nav-target">
Deep Dive Target
</h3>
<p data-testid="hash-nav-target-copy">The target should be scrolled into view.</p>
<p>
<Link href="/counter">Open counter after deep scroll</Link>
</p>
</section>
)
9 changes: 9 additions & 0 deletions e2e/app/mount-only/+page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { onMount } from 'eclipsa'

export default () => {
onMount(() => {
const element = document.querySelector('[data-testid="mount-only-state"]')!
element.textContent = 'mounted'
})
return <p data-testid="mount-only-state">waiting</p>
}
14 changes: 14 additions & 0 deletions e2e/app/query-nav/+page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Link, loader } from 'eclipsa'

const useQuery = loader((c) => ({ value: c.req.query('q') ?? 'one' }))

export default () => {
const query = useQuery()
return (
<section>
<h1>Query navigation</h1>
<p data-testid="query-result">{query.data?.value ?? 'loading'}</p>
<Link href="?q=two">Load second query</Link>
</section>
)
}
33 changes: 33 additions & 0 deletions e2e/app/route-props/+layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Link, useLocation, useSignal } from 'eclipsa'
import type { JSX } from 'eclipsa/jsx-runtime'

const RouteLabel = (props: { label: string }) => {
const count = useSignal(0)
return (
<button type="button" onClick={() => count.value++}>
{props.label}: {count.value}
</button>
)
}

export default (props: { children: JSX.Childable }) => {
const location = useLocation()
const labels = location.pathname.endsWith('/b')
? ['Second heading', 'Extra heading']
: ['First heading']
return (
<div>
<nav>
{labels.map((label) => (
<a key={label} href={'#' + label}>
{label}
</a>
))}
</nav>
<RouteLabel label={location.pathname} />
<Link href="/route-props/a">Route A</Link>
<Link href="/route-props/b">Route B</Link>
{props.children}
</div>
)
}
1 change: 1 addition & 0 deletions e2e/app/route-props/a/+page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default () => <p>Route A body</p>
1 change: 1 addition & 0 deletions e2e/app/route-props/b/+page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default () => <p>Route B body</p>
11 changes: 11 additions & 0 deletions e2e/app/slot-motion-nav/+layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@ export default (props: { children?: unknown }) => {
>
Toggle nav
</button>
<button
type="button"
data-testid="slot-motion-nav-async-toggle"
onClick={() => {
setTimeout(() => {
open.value = !open.value
}, 0)
}}
>
Toggle nav asynchronously
</button>
<span data-testid="slot-motion-nav-toggle-state">{open.value ? 'open' : 'closed'}</span>
<motion.div
data-testid="slot-motion-nav-panel"
Expand Down
9 changes: 9 additions & 0 deletions e2e/app/visible-redirect/+page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { onVisible, useNavigate } from 'eclipsa'

export default () => {
const navigate = useNavigate()
onVisible(() => {
void navigate('/counter', { replace: true })
})
return <p>Opening counter</p>
}
21 changes: 8 additions & 13 deletions e2e/scripts/run-playwright.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { existsSync } from 'node:fs'
import { createServer } from 'node:net'
import path from 'node:path'
import { resolveNodeBinary } from './node-binary.ts'
import { stopChildProcess } from './stop-child-process.ts'

const host = '127.0.0.1'
const cwd = process.cwd()
Expand Down Expand Up @@ -91,6 +92,7 @@ const run = async () => {
const devServer = spawn(nodeBinaryPath, devArgs, {
cwd,
env,
detached: process.platform !== 'win32',
stdio: ['ignore', 'inherit', 'inherit'],
})
const waitForDevServerExit = () =>
Expand All @@ -105,17 +107,14 @@ const run = async () => {
})
})

const terminateDevServer = () => {
if (!devServer.killed) {
devServer.kill('SIGTERM')
}
}
let shutdownPromise: Promise<void> | undefined
const terminateDevServer = () =>
(shutdownPromise ??= stopChildProcess(devServer, 5_000, process.platform !== 'win32'))

const exitSignals = ['SIGINT', 'SIGTERM'] as const
for (const signal of exitSignals) {
process.on(signal, () => {
terminateDevServer()
process.exit(1)
void terminateDevServer().finally(() => process.exit(1))
})
}

Expand Down Expand Up @@ -143,14 +142,10 @@ const run = async () => {
})

if (exitCode !== 0) {
process.exit(exitCode)
process.exitCode = exitCode
}
} finally {
terminateDevServer()
await new Promise((resolve) => {
devServer.once('close', resolve)
setTimeout(resolve, 5_000)
})
await terminateDevServer()
}
}

Expand Down
57 changes: 57 additions & 0 deletions e2e/scripts/stop-child-process.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { ChildProcess } from 'node:child_process'
import { EventEmitter } from 'node:events'
import { expect, test, vi } from 'vitest'
import { stopChildProcess } from './stop-child-process.ts'

const fakeChild = (graceful: boolean) => {
const child = new EventEmitter() as ChildProcess
child.exitCode = null
child.signalCode = null
const signals: string[] = []
child.kill = (signal) => {
signals.push(String(signal))
if (graceful || signal === 'SIGKILL') {
queueMicrotask(() => {
child.signalCode = signal as NodeJS.Signals
child.emit('close', null, signal)
})
}
return true
}
return { child, signals }
}

test('stops a child gracefully without leaving a shutdown timer', async () => {
const { child, signals } = fakeChild(true)
await stopChildProcess(child, 10)
await new Promise((resolve) => setTimeout(resolve, 20))
expect(signals).toEqual(['SIGTERM'])
})

test('forces shutdown when the dev server ignores SIGTERM', async () => {
const { child, signals } = fakeChild(false)
await stopChildProcess(child, 10)
expect(signals).toEqual(['SIGTERM', 'SIGKILL'])
})

test('leaves an already exited child alone', async () => {
const { child, signals } = fakeChild(true)
child.exitCode = 0
await stopChildProcess(child, 10)
expect(signals).toEqual([])
})

test('stops descendants even when their launcher has already exited', async () => {
const { child } = fakeChild(true)
Object.assign(child, { pid: 12345, exitCode: 0 })
const kill = vi.spyOn(process, 'kill').mockReturnValue(true)
try {
await stopChildProcess(child, 10, true)
expect(kill.mock.calls).toEqual([
[-12345, 'SIGTERM'],
[-12345, 'SIGKILL'],
])
} finally {
kill.mockRestore()
}
})
35 changes: 35 additions & 0 deletions e2e/scripts/stop-child-process.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { ChildProcess } from 'node:child_process'

export const stopChildProcess = async (
child: ChildProcess,
graceMs = 5_000,
processGroup = false,
) => {
const kill = (signal: NodeJS.Signals) => {
if (processGroup && child.pid !== undefined) {
try {
process.kill(-child.pid, signal)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error
}
} else {
child.kill(signal)
}
}
if (processGroup) {
kill('SIGTERM')
await new Promise((resolve) => setTimeout(resolve, graceMs))
// The launcher can exit before its Vite descendants release inherited pipes.
kill('SIGKILL')
return
}
if (child.exitCode !== null || child.signalCode !== null) return
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => kill('SIGKILL'), graceMs)
child.once('close', () => {
clearTimeout(timeout)
resolve()
})
kill('SIGTERM')
})
}
Loading
Loading