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
70 changes: 53 additions & 17 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -643,7 +643,23 @@ jobs:
- run: pnpm install --frozen-lockfile

- name: Unit Tests
run: pnpm run test.unit && echo ok > unit-tests-completed.txt
run: pnpm run test.unit

- name: Benchmark Tests
if: matrix.settings.host == 'ubuntu-latest'
run: pnpm run test.bench

save-unit-tests-cache:
name: Save Unit Tests Cache
runs-on: ubuntu-latest
needs:
- changes
- test-unit
if: always() && needs.changes.outputs.build-unit == 'true' && needs.test-unit.result == 'success'

steps:
- name: Mark unit tests complete
run: echo ok > unit-tests-completed.txt

- name: Save unit tests cache
uses: actions/cache/save@v4
Expand Down Expand Up @@ -786,6 +802,42 @@ jobs:
- name: CLI E2E Tests
run: pnpm run test.e2e.cli.${{ matrix.settings.browser }}

save-e2e-tests-cache:
name: Save E2E Tests Cache
runs-on: ubuntu-latest
needs:
- changes
- test-e2e
if: always() && needs.changes.outputs.build-e2e == 'true' && needs.test-e2e.result == 'success'

steps:
- name: Mark e2e tests complete
run: echo ok > e2e-tests-completed.txt

- name: Save e2e tests cache
uses: actions/cache/save@v4
with:
key: ${{ needs.changes.outputs.hash-e2e }}
path: e2e-tests-completed.txt

save-cli-e2e-tests-cache:
name: Save CLI E2E Tests Cache
runs-on: ubuntu-latest
needs:
- changes
- test-cli-e2e
if: always() && needs.changes.outputs.build-cli-e2e == 'true' && needs.test-cli-e2e.result == 'success'

steps:
- name: Mark cli e2e tests complete
run: echo ok > cli-e2e-tests-completed.txt

- name: Save cli-e2e tests cache
uses: actions/cache/save@v4
with:
key: ${{ needs.changes.outputs.hash-cli-e2e }}
path: cli-e2e-tests-completed.txt

########### LINT PACKAGES ############
lint-package:
name: Lint Package
Expand Down Expand Up @@ -851,30 +903,14 @@ jobs:
run: |
if [ "${{ needs.test-e2e.result }}" != success ] ; then
exit 1
else
echo ok > e2e-tests-completed.txt
fi
- name: Save e2e tests cache
if: needs.test-e2e.result != 'skipped'
uses: actions/cache/save@v4
with:
key: ${{ needs.changes.outputs.hash-e2e }}
path: e2e-tests-completed.txt

- name: Verify test-cli-e2e
if: needs.test-cli-e2e.result != 'skipped'
run: |
if [ "${{ needs.test-cli-e2e.result }}" != success ] ; then
exit 1
else
echo ok > cli-e2e-tests-completed.txt
fi
- name: Save cli-e2e tests cache
if: needs.test-cli-e2e.result != 'skipped'
uses: actions/cache/save@v4
with:
key: ${{ needs.changes.outputs.hash-cli-e2e }}
path: cli-e2e-tests-completed.txt

- name: Checkout
uses: actions/checkout@v5
Expand Down
166 changes: 102 additions & 64 deletions e2e/qwik-cli-e2e/tests/serve.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,16 @@ for (const type of ['empty', 'playground'] as QwikProjectType[]) {
);
assert.equal(existsSync(global.tmpDir), true);

const counterComponentPath = join(
global.tmpDir,
'src/components/starter/counter/counter.tsx'
);
const originalCounterContent = readFileSync(counterComponentPath, 'utf-8');

const browser = await playwright[browserType].launch();
try {
const page = await browser.newPage();

// Collect console messages for debugging
const consoleLogs: string[] = [];
page.on('console', (msg) => {
consoleLogs.push(`[${msg.type()}] ${msg.text()}`);
Expand All @@ -75,49 +80,31 @@ for (const type of ['empty', 'playground'] as QwikProjectType[]) {
await page.goto(host);
log('Page loaded');

// The counter's + button (last button in the counter component)
// Counter uses CSS modules so we can't rely on class names.
// The buttons contain "-" and "+" text.
const plusBtn = page.locator('button', { hasText: '+' }).first();
await plusBtn.waitFor({ timeout: 10000 });
log('Found + button');

// Click the + button 3 times to change counter state (starts at 70)
await plusBtn.click();
await plusBtn.click();
await plusBtn.click();
log('Clicked + button 3 times');

// Verify counter changed to 73 (the gauge renders value in a <span>)
await page.waitForFunction(() => document.body.textContent?.includes('73'), {
timeout: 10000,
});
await clickUntilCounterReaches(page, plusBtn, 73, 6, 10000);
log('Counter is at 73');

// Modify the counter component (whose QRL segments are loaded on the client
// because we clicked the buttons). Add a visible marker to verify the update.
const counterComponentPath = join(
global.tmpDir,
'src/components/starter/counter/counter.tsx'
);
const counterContent = readFileSync(counterComponentPath, 'utf-8');
writeFileSync(
counterComponentPath,
counterContent.replace(
`<Gauge value={count.value} />`,
`<span data-testid="hmr-marker">HMR-OK</span><Gauge value={count.value} />`
)
);
log('Modified counter.tsx');

// Wait for HMR to apply the update (new marker should appear without full reload)
const markerText = `HMR-OK-${Date.now()}`;
writeFileSync(counterComponentPath, withHmrMarker(originalCounterContent, markerText));
log(`Modified counter.tsx with marker ${markerText}`);

try {
await page.waitForFunction(
() => !!document.querySelector('[data-testid="hmr-marker"]'),
{ timeout: 15000 }
(expectedMarker) => {
const marker = document.querySelector('[data-testid="hmr-marker"]');
const gaugeValue = document.querySelector('._value_1v6hy_9, [class*="value"]');
return (
marker?.textContent === expectedMarker &&
gaugeValue?.textContent?.trim() === '73'
);
},
markerText,
{ timeout: 20000 }
);
} catch (e) {
// Dump debug info on failure
const hasMarker = await page.locator('[data-testid="hmr-marker"]').count();
const bodyText = await page.textContent('body');
log(
Expand All @@ -127,18 +114,19 @@ for (const type of ['empty', 'playground'] as QwikProjectType[]) {
throw e;
}

// Verify the HMR marker appeared
const markerText = await page.locator('[data-testid="hmr-marker"]').textContent();
expect(markerText).toBe('HMR-OK');
const markerValue = await page.locator('[data-testid="hmr-marker"]').textContent();
expect(markerValue).toBe(markerText);

// Verify counter state is preserved (not reset to initial value 70)
// If HMR works correctly, the counter should still show 73
// If a full-reload happened, it would reset to the initial value of 70
const bodyText = await page.textContent('body');
expect(bodyText).toContain('73');

log('HMR state preservation verified: content updated, counter state preserved');
} finally {
try {
writeFileSync(counterComponentPath, originalCounterContent);
} catch (e) {
log(`Error restoring counter.tsx: ${e.message}`);
}
await browser.close();
try {
await promisifiedTreeKill(p.pid!, 'SIGKILL');
Expand All @@ -154,7 +142,6 @@ for (const type of ['empty', 'playground'] as QwikProjectType[]) {
const host = `http://localhost:${SERVE_PORT}/`;
await assertHostUnused(host);

// First build the app
const buildProcess = await runCommandUntil(`npm run build`, global.tmpDir, (output) => {
return output.includes('dist/build') || output.includes('built in');
});
Expand All @@ -165,7 +152,6 @@ for (const type of ['empty', 'playground'] as QwikProjectType[]) {
log(`Error terminating build process: ${e.message}`);
}

// Now run the preview
const p = await runCommandUntil(
`npm run preview -- --no-open --port ${SERVE_PORT}`,
global.tmpDir,
Expand All @@ -176,13 +162,12 @@ for (const type of ['empty', 'playground'] as QwikProjectType[]) {

assert.equal(existsSync(global.tmpDir), true);

// Wait a bit for the server to fully start
await new Promise((resolve) => setTimeout(resolve, 2000));

const res = await fetch(host, { headers: { accept: 'text/html' } }).then((r) => r.text());
console.log('** res', res);
const res = await waitForHttpText(
host,
(html) => (type === 'playground' ? html.includes('fantastic') : html.includes('Hi')),
20000
);

// Check for the appropriate content based on template type
if (type === 'playground') {
expect(res).toContain('fantastic');
} else if (type === 'empty') {
Expand All @@ -199,22 +184,75 @@ for (const type of ['empty', 'playground'] as QwikProjectType[]) {
});
}

async function expectHtmlOnARootPage(host: string) {
expect((await getPageHtml(host)).querySelector('.container h1')?.textContent).toBe(
`So fantasticto have you here`
function withHmrMarker(counterContent: string, markerText: string) {
return counterContent.replace(
`<Gauge value={count.value} />`,
`<span data-testid="hmr-marker">${markerText}</span><Gauge value={count.value} />`
);
const heroComponentPath = join(global.tmpDir, `src/components/starter/hero/hero.tsx`);
const heroComponentTextContent = readFileSync(heroComponentPath, 'utf-8');
writeFileSync(
heroComponentPath,
heroComponentTextContent.replace(
`to have <span class="highlight">you</span> here`,
`to have <span class="highlight">e2e tests</span> here`
)
);
// wait for the arbitrary amount of time before the app is reloaded
await new Promise((r) => setTimeout(r, 2000));
expect((await getPageHtml(host)).querySelector('.container h1')?.textContent).toBe(
`So fantasticto have e2e tests here`
}

async function clickUntilCounterReaches(
page: playwright.Page,
plusBtn: playwright.Locator,
expectedValue: number,
maxClicks: number,
timeoutMs: number
) {
const expectedText = String(expectedValue);
const deadline = Date.now() + timeoutMs;
for (let i = 0; i < maxClicks; i++) {
await plusBtn.click();
try {
await page.waitForFunction(
(text) => {
const gaugeValue = document.querySelector('._value_1v6hy_9, [class*="value"]');
return gaugeValue?.textContent?.trim() === text;
},
expectedText,
{ timeout: Math.max(Math.min(deadline - Date.now(), 2000), 1) }
);
return;
} catch {
// ignore
}
}
await page.waitForFunction(
(text) => {
const gaugeValue = document.querySelector('._value_1v6hy_9, [class*="value"]');
return gaugeValue?.textContent?.trim() === text;
},
expectedText,
{ timeout: Math.max(deadline - Date.now(), 1) }
);
}

async function waitForHttpText(
host: string,
matcher: (html: string) => boolean,
timeoutMs: number
): Promise<string> {
let lastHtml = '';
await waitFor(async () => {
const html = await fetch(host, { headers: { accept: 'text/html' } }).then((r) => r.text());
lastHtml = html;
return matcher(html);
}, timeoutMs);
return lastHtml;
}

async function waitFor(
callback: () => Promise<boolean>,
timeoutMs: number,
intervalMs = 250
): Promise<void> {
const timeoutAt = Date.now() + timeoutMs;
while (Date.now() < timeoutAt) {
try {
if (await callback()) {
return;
}
} catch {}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
throw new Error(`Timed out after ${timeoutMs}ms`);
}
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,12 @@
"test.rust": "make test",
"test.rust.bench": "make benchmark",
"test.rust.update": "make test-update",
"test.bench": "pnpm test.bench.timing && pnpm test.bench.memory",
"test.bench.memory": "node --expose-gc --require ./scripts/runBefore.ts scripts/validate-benchmark-memory.ts",
"test.bench.memory.update": "node --expose-gc --require ./scripts/runBefore.ts scripts/validate-benchmark-memory.ts --update",
"test.bench.timing": "node --require ./scripts/runBefore.ts scripts/validate-benchmarks.ts",
"test.bench.timing.update": "node --require ./scripts/runBefore.ts scripts/validate-benchmarks.ts --update",
"test.bench.update": "pnpm test.bench.timing.update && pnpm test.bench.memory.update",
"test.unit": "vitest packages",
"test.unit.debug": "vitest --inspect-brk packages",
"test.vite": "playwright test e2e/qwik-e2e/tests/qwikrouter --browser=chromium --config e2e/qwik-e2e/playwright.config.ts",
Expand Down
Loading
Loading