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
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,26 @@ _:ng3 {
}`)
```

## RDF 1.2 support

RDF 1.2 handling in eye-js is **experimental**: the underlying EYE reasoner officially targets the
[Notation3 specification](https://w3c.github.io/N3/spec/), and its parsing of RDF 1.1/1.2 syntax is
experimental ([#1854](https://github.com/eyereasoner/eye-js/issues/1854)). For full RDF 1.2 inputs,
the recommended path is to convert to N3 first, for example with
[`n3gen`](https://github.com/eyereasoner/eyeling/blob/main/tools/n3gen.js) from
[eyeling](https://github.com/eyereasoner/eyeling).

The behaviour that is supported follows RDF 1.2 semantics:
- Annotation and quoted-triple syntax desugars to a blank-node reifier: `<< :s :p :o >> :is true.`
is shorthand for `_:r rdf:reifies <<( :s :p :o )>>. _:r :is true.` Triple terms only occur as the
object of `rdf:reifies`; the pre-1.2 RDF-star CG form (a quoted triple directly in the subject or
object position) is no longer produced.
- [N3.js](https://github.com/rdfjs/N3.js) v2, used for quad input/output since
[#1853](https://github.com/eyereasoner/eye-js/pull/1853), and EYE agree on this model, so string
and quad i/o behave consistently.
- The [`dataStar` test cases](https://github.com/eyereasoner/eye-js/blob/main/data/socrates.ts)
guard this behaviour.

## Cite

If you are using or extending eye-js as part of a scientific publication, we would appreciate a citation of our [zenodo artefact](https://zenodo.org/doi/10.5281/zenodo.12211023).
Expand Down
35 changes: 35 additions & 0 deletions __test_utils__/dist-dynamic-import.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html>
<body>
<div id="result"></div>
<div id="error"></div>
</body>
<!-- Consume the published bundle exactly like the "Dynamic imports" section
of the README: an ES module dynamically importing latest/dynamic-import.js.

Unlike the script-tag case (which drives examples/prebuilt/index.html
directly), no example in examples/ consumes latest/dynamic-import.js --
the rollup example bundles the npm package instead -- so this minimal
page is the only consumer of that entrypoint. It reuses the same socrates
data and derived-triple assertion as the prebuilt example. -->
<script type="module">
const data = `@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#>.
@prefix : <http://example.org/socrates#>.

:Socrates a :Human.
:Human rdfs:subClassOf :Mortal.

{?A rdfs:subClassOf ?B. ?S a ?A} => {?S a ?B}.`;

try {
const { eyereasoner } = await import('/latest/dynamic-import.js');
document.getElementById('result').textContent = await eyereasoner.n3reasoner(
data,
undefined,
{ output: 'derivations' },
);
} catch (error) {
document.getElementById('error').textContent = String(error);
}
</script>
</html>
1 change: 1 addition & 0 deletions __test_utils__/serve.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type http from 'http';

export declare function createTestApp(): http.Server;
export declare function createDistTestApp(): http.Server;
50 changes: 50 additions & 0 deletions __test_utils__/serve.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,53 @@ module.exports.createTestApp = function createTestApp() {

return app;
}

// Serves the release artifact shape (bundle/latest/) the same way the GitHub
// Pages branch does: as a plain static directory. Any chunk or asset that the
// published bundle requests at runtime must therefore actually exist in the
// released file set, or the test pages fail to load.
module.exports.createDistTestApp = function createDistTestApp() {
const app = express();

const limiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 1_000,
standardHeaders: true,
legacyHeaders: false,
});

app.use(limiter);

// Serve the real prebuilt example (examples/prebuilt/index.html) rather than
// a test-only copy of it, so the example itself is under test and there is a
// single source of truth for script-tag consumption. The only change made --
// at serve time, never to the committed example -- is pointing its script tag
// at the locally built release artifact instead of the published GitHub Pages
// bundle.
app.get('/', (_, res) => {
const example = fs.readFileSync(path.join(__dirname, '..', 'examples', 'prebuilt', 'index.html'), 'utf-8');
const rewritten = example.replace(
/src="https:\/\/eyereasoner\.github\.io\/eye-js\/[^"]+\/index\.js"/,
'src="/latest/index.js"',
);
if (rewritten === example) {
// Fail loudly: without the rewrite the page would silently load the
// *published* bundle over the network and the test would not be testing
// the freshly built dist at all
throw new Error('Could not find the GitHub Pages script src in examples/prebuilt/index.html to point at the local dist');
}
res.setHeader('content-type', 'text/html');
res.send(rewritten);
});

app.get('/dynamic-import', (_, res) => {
res.setHeader('content-type', 'text/html');
fs.createReadStream(path.join(__dirname, 'dist-dynamic-import.html')).pipe(res);
});

// express.static sets `Content-Type: text/javascript; charset=utf-8`, which
// is required for WASM streaming instantiation (see "Serving Files" in the README)
app.use('/latest', express.static(path.join(__dirname, '..', 'bundle', 'latest')));

return app;
}
115 changes: 115 additions & 0 deletions __tests__/e2e-dist-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { execSync } from 'child_process';
import * as fs from 'fs';
import type { Server } from 'http';
import * as path from 'path';
import { firefox, chromium, type BrowserType } from 'playwright';
import { createDistTestApp } from '../__test_utils__/serve';

// Generous timeout for the reasoner to produce a result on slow CI runners,
// kept within the overall 120s jest timeout of each test below
const RESULT_TIMEOUT = 90_000;

const root = path.join(__dirname, '..');
const bundleDir = path.join(root, 'bundle');

describe('Testing the built browser dist', () => {
let server: Server;

// Recreate the released artifact shape that consumers load from GitHub Pages
// (bundle/latest/index.js and bundle/latest/dynamic-import.js). The webpack
// bundle already exists at bundle/index.js because `npm run test:unit` runs
// `bundle:webpack` first; the `bundle:latest` release script (the same one the
// release workflow runs) then derives dynamic-import.js and the latest/ copies.
beforeAll(() => {
const webpackBundle = path.join(bundleDir, 'index.js');
if (!fs.existsSync(webpackBundle)) {
throw new Error('bundle/index.js is missing; run `npm run bundle:webpack` first (`npm run test:unit` does this automatically)');
}

const versionDir = path.join(bundleDir, 'dist-test');
fs.mkdirSync(versionDir, { recursive: true });
fs.copyFileSync(webpackBundle, path.join(versionDir, 'index.js'));

execSync('npm run bundle:latest -- --name=vdist-test', { cwd: root, stdio: 'inherit' });

server = createDistTestApp().listen(3002);
}, 120_000);

afterAll(async () => {
await new Promise((res, rej) => {
server.on('close', res);
server.on('error', rej);
server.close(res);
});
});

([[firefox, 'firefox'], [chromium, 'chromium']] as [BrowserType<{}>, string][]).forEach(
([browserType, browserName]) => {
// The script-tag path is tested by driving the real prebuilt example
// (examples/prebuilt/index.html) with its script src pointed at the
// locally built dist (see createDistTestApp), so a packaging regression
// in the example itself is caught too
it(`should complete a reasoning round-trip in the prebuilt example via a script tag (latest/index.js) in ${browserName}`, async () => {
const browser = await browserType.launch();

// Ensure the browser is always closed, even when an expectation fails;
// a leaked browser stops the jest worker from exiting and hangs CI
try {
const page = await browser.newPage();

// The example has no error element, so collect page-level errors
// (uncaught exceptions and unhandled rejections) for diagnostics
const pageErrors: string[] = [];
page.on('pageerror', (error) => { pageErrors.push(String(error)); });

await page.goto('http://localhost:3002/');

// Drive the example the way a user does: click Execute, then wait for
// the result to be rendered rather than sleeping for a fixed time
await page.click('button[id=execute]');
try {
await page.waitForFunction(
() => (document.querySelector('div[id=result]')?.textContent ?? '').trim() !== '',
undefined,
{ timeout: RESULT_TIMEOUT },
);
} catch (error) {
// Surface what the page reported instead of a bare wait timeout
throw pageErrors.length > 0 ? new Error(`Example page errored: ${pageErrors.join('\n')}`) : error;
}

expect(pageErrors).toEqual([]);
// The derived triple is not part of the input data, so its presence
// proves the bundle parsed the input and reasoned over it
await expect(page.textContent('div[id=result]')).resolves.toContain(':Socrates a :Mortal');
} finally {
await browser.close();
}
}, 120_000);

it(`should complete a reasoning round-trip via a dynamic import (latest/dynamic-import.js) in ${browserName}`, async () => {
const browser = await browserType.launch();

try {
const page = await browser.newPage();

await page.goto('http://localhost:3002/dynamic-import');

// Wait for the page to finish the reasoning round-trip (or to report
// an error) rather than sleeping for a fixed amount of time
await page.waitForFunction(
() => (document.querySelector('div[id=result]')?.textContent ?? '').trim() !== ''
|| (document.querySelector('div[id=error]')?.textContent ?? '').trim() !== '',
undefined,
{ timeout: RESULT_TIMEOUT },
);

await expect(page.textContent('div[id=error]').then((r) => r?.trim())).resolves.toEqual('');
await expect(page.textContent('div[id=result]')).resolves.toContain(':Socrates a :Mortal');
} finally {
await browser.close();
}
}, 120_000);
},
);
});
Loading