Testing¶
Guide to testing IFClite.
Overview¶
Running Tests¶
Test Suites¶
The root package.json wires up these suites (most need fixtures, fetched
once with pnpm fixtures):
| Command | What it runs |
|---|---|
pnpm test |
All package unit tests via Turbo (turbo test, Vitest per package) |
pnpm test:e2e |
Playwright viewer end-to-end tests (project viewer-e2e) |
pnpm test:e2e:ci |
Playwright e2e, CI variant (project viewer-e2e-ci) |
pnpm test:integration |
Cross-package integration pipeline (tests/integration.test.ts) |
pnpm test:api |
Server API tests (tests/api/) |
pnpm test:wasm-contract |
Real WASM boundary contract tests (run pnpm build:wasm first, or it skips) |
pnpm test:ids-corpus |
IDS validation corpus |
cargo test --workspace |
All Rust tests (run from the repo root) |
TypeScript Tests¶
# Run all TypeScript tests
pnpm test
# Run specific package
cd packages/parser && pnpm test
# Watch mode
cd packages/parser && pnpm exec vitest --watch
# With coverage
cd packages/parser && pnpm exec vitest run --coverage
Rust Tests¶
The Cargo workspace root is the repo root:
# Run all Rust tests
cargo test --workspace
# Run specific crate
cargo test -p ifc-lite-core
# Run specific test
cargo test parse_entity
# With output
cargo test -- --nocapture
Fuzzing the STEP parser¶
The entity parser has a coverage-guided fuzz target (rust/core/fuzz/). It is
a standalone crate, so it is not built by cargo build/cargo test or by the
PR test workflow; a scheduled workflow (.github/workflows/fuzz.yml) runs it
weekly and on demand. Run it locally with
cargo-fuzz (needs the nightly
toolchain, already pinned):
The contract under fuzz is that parse_entity never panics, hangs, or overflows
on arbitrary bytes (only returns Ok/Err). tests/malformed_input.rs pins the
same contract for specific inputs in normal CI.
Writing Tests¶
TypeScript Tests (Vitest)¶
// src/__tests__/parser.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { IfcParser } from '../parser';
describe('IfcParser', () => {
let parser: IfcParser;
beforeEach(() => {
parser = new IfcParser();
});
describe('parse', () => {
it('should parse a simple IFC file', async () => {
const content = `
ISO-10303-21;
HEADER;
FILE_DESCRIPTION((''), '2;1');
FILE_NAME('test.ifc', '', '', '', '', '', '');
FILE_SCHEMA(('IFC4'));
ENDSEC;
DATA;
#1=IFCPROJECT('0001',#2,$,$,$,$,$,$,$);
ENDSEC;
END-ISO-10303-21;
`;
const buffer = new TextEncoder().encode(content).buffer;
const result = await parser.parseColumnar(buffer);
expect(result.entityCount).toBe(1);
expect(result.schemaVersion).toBe('IFC4');
});
it('should extract entity properties', async () => {
const buffer = await loadTestFile('test-model.ifc');
const result = await parser.parseColumnar(buffer);
const wallIds = result.entityIndex.byType.get('IFCWALL') ?? [];
expect(wallIds.length).toBeGreaterThan(0);
expect(result.entities.getGlobalId(wallIds[0])).toMatch(/^[0-9a-zA-Z_$]+$/);
});
it('should handle streaming geometry', async () => {
const buffer = await loadTestFile('large-model.ifc');
const geometry = new GeometryProcessor();
await geometry.init();
const batches: number[] = [];
for await (const event of geometry.processStreaming(new Uint8Array(buffer))) {
if (event.type === 'batch') {
batches.push(event.meshes.length);
}
}
expect(batches.length).toBeGreaterThan(0);
expect(batches.reduce((a, b) => a + b, 0)).toBeGreaterThan(0);
});
it('should throw on invalid file', async () => {
const buffer = new TextEncoder().encode('invalid content').buffer;
await expect(parser.parseColumnar(buffer)).rejects.toThrow();
});
});
});
Rust Tests¶
// src/parser/tests.rs
use super::*;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_entity_ref() {
let input = b"#123=";
let (_, token) = entity_ref(input).unwrap();
assert_eq!(token, Token::EntityRef(123));
}
#[test]
fn test_parse_string_literal() {
let input = b"'Hello World'";
let (_, token) = string_literal(input).unwrap();
assert_eq!(token, Token::String(b"Hello World"));
}
#[test]
fn test_parse_entity() {
let input = b"#1=IFCWALL('guid',$,$,$,$,$,$,$);";
let (id, type_name, attrs) = parse_entity(input).unwrap();
assert_eq!(id, 1);
assert_eq!(type_name, b"IFCWALL");
assert_eq!(attrs.len(), 8);
}
#[test]
fn test_entity_scanner() {
let input = b"DATA;\n#1=IFCPROJECT();\n#2=IFCSITE();\nENDSEC;";
let mut scanner = EntityScanner::new();
let index = scanner.scan(input).unwrap();
assert_eq!(index.len(), 2);
assert!(index.get(1).is_some());
assert!(index.get(2).is_some());
}
#[test]
fn test_decode_entity() {
let input = include_bytes!("../../test-data/simple.ifc");
let scanner = EntityScanner::new();
let index = scanner.scan(input).unwrap();
let decoder = EntityDecoder::new(input, &index);
let entity = decoder.decode(1).unwrap();
assert_eq!(entity.ifc_type, IfcType::IfcProject);
}
#[test]
#[should_panic(expected = "EntityNotFound")]
fn test_decode_missing_entity() {
let input = b"DATA;\n#1=IFCPROJECT();\nENDSEC;";
let scanner = EntityScanner::new();
let index = scanner.scan(input).unwrap();
let decoder = EntityDecoder::new(input, &index);
decoder.decode(999).unwrap(); // Should panic
}
}
Integration Tests¶
// tests/integration/full-parse.test.ts
import { describe, it, expect } from 'vitest';
import { IfcParser } from '@ifc-lite/parser';
import { GeometryProcessor } from '@ifc-lite/geometry';
import { Renderer } from '@ifc-lite/renderer';
import { readFileSync } from 'fs';
import { join } from 'path';
describe('Full Parse Integration', () => {
it('should parse and render a real IFC file', async () => {
// Load test file. readFileSync returns a Node Buffer backed by a shared
// pool, so `.buffer` would expose the whole pool — not just this file's
// bytes. Copy the exact bytes into a standalone Uint8Array first.
const filePath = join(__dirname, '../fixtures/test-model.ifc');
const bytes = new Uint8Array(readFileSync(filePath));
// Parse metadata
const parser = new IfcParser();
const store = await parser.parseColumnar(bytes);
// Extract geometry separately
const geometry = new GeometryProcessor();
await geometry.init();
const meshes = [];
for await (const event of geometry.processAdaptive(bytes)) {
if (event.type === 'batch') meshes.push(...event.meshes);
}
// Verify parsing
expect(store.entityCount).toBeGreaterThan(0);
expect(meshes.length).toBeGreaterThan(0);
// Verify geometry
const mesh = meshes[0];
expect(mesh.positions.length).toBeGreaterThan(0);
expect(mesh.indices.length).toBeGreaterThan(0);
expect(mesh.indices.length % 3).toBe(0); // Valid triangles
});
it('should handle large files with streaming', async () => {
const filePath = join(__dirname, '../fixtures/large-model.ifc');
const buffer = readFileSync(filePath);
const parser = new IfcParser();
const store = await parser.parseColumnar(buffer.buffer);
const geometry = new GeometryProcessor();
await geometry.init();
let totalMeshes = 0;
for await (const event of geometry.processStreaming(new Uint8Array(buffer))) {
if (event.type === 'batch') {
totalMeshes += event.meshes.length;
}
}
expect(totalMeshes).toBeGreaterThan(0);
expect(store.entityCount).toBeGreaterThan(0);
});
});
Test Fixtures¶
IFC/IFCX model fixtures live under tests/models/ but are not stored in git.
They are catalogued in tests/models/manifest.json (path, SHA-256, size) and
fetched on demand from a GitHub Release:
# Populate the fixtures (idempotent; skips files whose hash already matches)
pnpm fixtures
# Verify presence and hashes without downloading
pnpm fixtures:check
# List missing or out-of-date paths
pnpm fixtures:list-missing
Every download is verified against the manifest's SHA-256 before being
written. Tests that need a missing fixture skip cleanly rather than fail.
tests/models/local/ is never managed by the manifest; it is reserved for
private fixtures kept on your own machine. See tests/models/README.md for
the maintainer flow (adding fixtures, rotating releases).
Creating Test Data¶
// tests/helpers/create-fixture.ts
export function createMinimalIfc(entities: string[]): ArrayBuffer {
const content = `
ISO-10303-21;
HEADER;
FILE_DESCRIPTION(('Test'), '2;1');
FILE_NAME('test.ifc', '', '', '', '', '', '');
FILE_SCHEMA(('IFC4'));
ENDSEC;
DATA;
${entities.join('\n')}
ENDSEC;
END-ISO-10303-21;
`;
return new TextEncoder().encode(content).buffer;
}
// Usage
const buffer = createMinimalIfc([
"#1=IFCPROJECT('proj123',$,$,$,$,$,$,$,$);",
"#2=IFCWALL('wall123',$,$,$,$,$,$,$);"
]);
Mocking¶
TypeScript Mocks¶
import { vi, describe, it, expect } from 'vitest';
// Mock WASM module
vi.mock('@ifc-lite/wasm', () => ({
IfcAPI: class MockIfcAPI {
parse() {
return { entityCount: 10 };
}
}
}));
// Mock fetch
vi.stubGlobal('fetch', vi.fn(() =>
Promise.resolve({
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0))
})
));
Rust tests do not use a mocking framework; they exercise real parsers and
geometry on fixture bytes (see rust/*/tests/).
Coverage¶
TypeScript Coverage¶
# Generate coverage report (Vitest, per package)
cd packages/parser && pnpm exec vitest run --coverage
# View HTML report
open coverage/index.html
Rust Coverage¶
# Install cargo-tarpaulin
cargo install cargo-tarpaulin
# Generate coverage
cargo tarpaulin --out Html
# View report
open tarpaulin-report.html
No coverage thresholds are enforced in CI; coverage reports are for local inspection.
Performance Tests¶
// tests/performance/parse.bench.ts
import { bench, describe } from 'vitest';
import { IfcParser } from '@ifc-lite/parser';
import { readFileSync } from 'fs';
describe('Parser Performance', () => {
const smallFile = readFileSync('fixtures/small.ifc');
const largeFile = readFileSync('fixtures/large.ifc');
bench('parse small file (columnar)', async () => {
const parser = new IfcParser();
await parser.parseColumnar(smallFile.buffer);
});
bench('parse large file (columnar)', async () => {
const parser = new IfcParser();
await parser.parseColumnar(largeFile.buffer);
});
bench('streaming geometry large file', async () => {
const geometry = new GeometryProcessor();
await geometry.init();
for await (const event of geometry.processStreaming(new Uint8Array(largeFile))) {
// Process batches
}
});
});
Run benchmarks:
# Vitest bench, from the package holding the .bench.ts file
pnpm exec vitest bench
# Viewer load benchmark (Playwright, project viewer-benchmark)
pnpm test:benchmark:viewer
CI/CD¶
GitHub Actions¶
The PR gate lives in .github/workflows/test.yml. It runs on Node 22 with
the Rust toolchain pinned by rust-toolchain.toml, and covers:
- WASM build (or a prebuilt-bundle fast path when no Rust code changed)
pnpm typecheckandpnpm lintpnpm test, thenpnpm test:integrationandpnpm test:wasm-contract- Playwright e2e via
pnpm test:e2e:ci cargo testfor the Rust workspace
Fixtures in CI use the same flow as local development: pnpm fixtures to
fetch, pnpm fixtures:check to verify hashes. Heavier suites (benchmarks,
determinism, fuzzing, IfcOpenShell parity) run in separate scheduled or
on-demand workflows under .github/workflows/.
Fixture Strategy in CI¶
Fixtures come from a GitHub Release, not Git LFS (the repo no longer uses LFS). The manifest-driven fetcher keeps CI reliable:
- Fetches are selective: only files listed in
tests/models/manifest.jsonthat are missing or hash-mismatched get downloaded. - Every file is verified against its SHA-256 from the manifest.
- Heavy corpus and benchmark runs live in dedicated scheduled workflows, so normal PR validation stays cheap.
Next Steps¶
- Setup - Development setup