diff --git a/README.md b/README.md index bb0893ca6..389669010 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/__test_utils__/dist-dynamic-import.html b/__test_utils__/dist-dynamic-import.html new file mode 100644 index 000000000..89c0b6af5 --- /dev/null +++ b/__test_utils__/dist-dynamic-import.html @@ -0,0 +1,35 @@ + + + +
+
+ + + + diff --git a/__test_utils__/serve.d.ts b/__test_utils__/serve.d.ts index 6c46c973d..69e265a9d 100644 --- a/__test_utils__/serve.d.ts +++ b/__test_utils__/serve.d.ts @@ -1,3 +1,4 @@ import type http from 'http'; export declare function createTestApp(): http.Server; +export declare function createDistTestApp(): http.Server; diff --git a/__test_utils__/serve.js b/__test_utils__/serve.js index e823937c5..4e64b393c 100644 --- a/__test_utils__/serve.js +++ b/__test_utils__/serve.js @@ -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; +} diff --git a/__tests__/e2e-dist-test.ts b/__tests__/e2e-dist-test.ts new file mode 100644 index 000000000..840cec285 --- /dev/null +++ b/__tests__/e2e-dist-test.ts @@ -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); + }, + ); +});