diff --git a/packages/kernel-test/package.json b/packages/kernel-test/package.json index 7353b12ea..c433659d9 100644 --- a/packages/kernel-test/package.json +++ b/packages/kernel-test/package.json @@ -82,8 +82,11 @@ "node": "^20 || >=22" }, "dependencies": { + "@agoric/store": "0.9.3-u19.0", "@endo/eventual-send": "^1.3.1", + "@endo/exo": "^1.5.9", "@endo/marshal": "^1.6.4", + "@endo/patterns": "^1.5.0", "@endo/promise-kit": "^1.1.10", "@ocap/kernel": "workspace:^", "@ocap/nodejs": "workspace:^", diff --git a/packages/kernel-test/src/exo.test.ts b/packages/kernel-test/src/exo.test.ts new file mode 100644 index 000000000..0ac41058a --- /dev/null +++ b/packages/kernel-test/src/exo.test.ts @@ -0,0 +1,229 @@ +import '@ocap/shims/endoify'; +import { Kernel, kunser } from '@ocap/kernel'; +import type { KRef } from '@ocap/kernel'; +import type { KernelDatabase } from '@ocap/store'; +import { makeSQLKernelDatabase } from '@ocap/store/sqlite/nodejs'; +import { waitUntilQuiescent } from '@ocap/utils'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { + extractVatLogs, + getBundleSpec, + makeKernel, + runTestVats, +} from './utils.ts'; + +const origStdoutWrite = process.stdout.write.bind(process.stdout); +let buffered: string = ''; +// @ts-expect-error Some type def used by lint is just wrong (compiler likes it ok, but lint whines) +process.stdout.write = (buffer: string, encoding, callback): void => { + buffered += buffer; + origStdoutWrite(buffer, encoding, callback); +}; + +const testSubcluster = { + bootstrap: 'exoTest', + forceReset: true, + vats: { + exoTest: { + bundleSpec: getBundleSpec('exo-vat'), + parameters: { + name: 'ExoTest', + }, + }, + }, +}; + +describe('virtual objects functionality', async () => { + let kernel: Kernel; + let kernelDatabase: KernelDatabase; + let bootstrapResult: unknown; + + beforeEach(async () => { + kernelDatabase = await makeSQLKernelDatabase({ + dbFilename: ':memory:', + }); + kernel = await makeKernel(kernelDatabase, true); + buffered = ''; + bootstrapResult = await runTestVats(kernel, testSubcluster); + await waitUntilQuiescent(100); + }); + + it('successfully creates and uses exo objects and scalar stores', async () => { + expect(bootstrapResult).toBe('exo-test-complete'); + const vatLogs = extractVatLogs(buffered); + expect(vatLogs).toStrictEqual([ + 'ExoTest: initializing state', + 'ExoTest: counter value from baggage: 0', + 'ExoTest: bootstrap()', + 'ExoTest: Created counter with initial value: 10', + 'ExoTest: Incremented counter by 5 to: 15', + 'ExoTest: ERROR: Increment with negative value should have failed', + 'ExoTest: Alice has 1 friends', + 'ExoTest: Added 2 entries to map store', + 'ExoTest: Added 2 entries to set store', + 'ExoTest: Retrieved Alice from map store', + 'ExoTest: Temperature at 25°C = 77°F', + 'ExoTest: After setting to 68°F, celsius is 20°C', + 'ExoTest: SimpleCounter initial value: 0', + 'ExoTest: SimpleCounter after +7: 7', + 'ExoTest: Updated baggage counter to: 7', + ]); + }, 30000); + + it('tests scalar store functionality', async () => { + buffered = ''; + const storeResult = await kernel.queueMessageFromKernel( + 'ko1', + 'testScalarStore', + [], + ); + await waitUntilQuiescent(100); + expect(kunser(storeResult)).toBe('scalar-store-tests-complete'); + const vatLogs = extractVatLogs(buffered); + expect(vatLogs).toStrictEqual([ + 'ExoTest: Map store size: 3', + 'ExoTest: Map store keys: alice, bob, charlie', + "ExoTest: Map has 'charlie': true", + 'ExoTest: Set store size: 3', + 'ExoTest: Set has Charlie: true', + ]); + }, 30000); + + it('can create and use objects through messaging', async () => { + buffered = ''; + const counterResult = await kernel.queueMessageFromKernel( + 'ko1', + 'createCounter', + [42], + ); + await waitUntilQuiescent(); + const counterRef = counterResult.slots[0] as KRef; + const incrementResult = await kernel.queueMessageFromKernel( + counterRef, + 'increment', + [5], + ); + // Verify the increment result + expect(kunser(incrementResult)).toBe(47); + await waitUntilQuiescent(); + const personResult = await kernel.queueMessageFromKernel( + 'ko1', + 'createPerson', + ['Dave', 35], + ); + await waitUntilQuiescent(); + const personRef = personResult.slots[0] as KRef; + await kernel.queueMessageFromKernel('ko1', 'createOrUpdateInMap', [ + 'dave', + personRef, + ]); + await waitUntilQuiescent(); + + // Get object from map store + const retrievedPerson = await kernel.queueMessageFromKernel( + 'ko1', + 'getFromMap', + ['dave'], + ); + await waitUntilQuiescent(); + // Verify the retrieved person object + expect(kunser(retrievedPerson)).toBe(personRef); + await kernel.queueMessageFromKernel('ko1', 'createOrUpdateInMap', [ + 'dave', + personRef, + ]); + await waitUntilQuiescent(100); + const vatLogs = extractVatLogs(buffered); + // Verify counter was created and used + expect(vatLogs).toStrictEqual([ + 'ExoTest: Created new counter with value: 42', + 'ExoTest: Created person Dave, age 35', + 'ExoTest: Added dave to map, size now: 3', + 'ExoTest: Found dave in map', + 'ExoTest: Updated dave in map', + ]); + }, 30000); + + it('tests exoClass type validation and behavior', async () => { + buffered = ''; + const exoClassResult = await kernel.queueMessageFromKernel( + 'ko1', + 'testExoClass', + [], + ); + await waitUntilQuiescent(100); + expect(kunser(exoClassResult)).toBe('exoClass-tests-complete'); + const vatLogs = extractVatLogs(buffered); + expect(vatLogs).toStrictEqual([ + 'ExoTest: Counter: 3 + 5 = 8', + 'ExoTest: Counter: 8 - 2 = 6', + 'ExoTest: Successfully caught type error: In "increment" method of (Counter): arg 0: string "foo" - Must be a number', + ]); + }, 30000); + + it('tests exoClassKit with multiple facets', async () => { + buffered = ''; + const exoClassKitResult = await kernel.queueMessageFromKernel( + 'ko1', + 'testExoClassKit', + [], + ); + await waitUntilQuiescent(100); + expect(kunser(exoClassKitResult)).toBe('exoClassKit-tests-complete'); + const vatLogs = extractVatLogs(buffered); + expect(vatLogs).toStrictEqual([ + 'ExoTest: 20°C = 68°F', + 'ExoTest: 32°F = 0°C', + 'ExoTest: Successfully caught cross-facet error: celsius.getFahrenheit is not a function', + ]); + }, 30000); + + it('tests temperature converter through messaging', async () => { + buffered = ''; + // Create a temperature converter starting at 100°C + const tempResult = await kernel.queueMessageFromKernel( + 'ko1', + 'createTemperature', + [100], + ); + await waitUntilQuiescent(); + // Get both facets from the result + const tempKit = tempResult; + const celsiusRef = tempKit.slots[0] as KRef; + const fahrenheitRef = tempKit.slots[1] as KRef; + // Get the celsius value + const celsiusResult = await kernel.queueMessageFromKernel( + celsiusRef, + 'getCelsius', + [], + ); + expect(kunser(celsiusResult)).toBe(100); + // Get the fahrenheit value + const fahrenheitResult = await kernel.queueMessageFromKernel( + fahrenheitRef, + 'getFahrenheit', + [], + ); + expect(kunser(fahrenheitResult)).toBe(212); + // Change the temperature using the fahrenheit facet + const setFahrenheitResult = await kernel.queueMessageFromKernel( + fahrenheitRef, + 'setFahrenheit', + [32], + ); + expect(kunser(setFahrenheitResult)).toBe(32); + // Verify that the celsius value changed + const newCelsiusResult = await kernel.queueMessageFromKernel( + celsiusRef, + 'getCelsius', + [], + ); + expect(kunser(newCelsiusResult)).toBe(0); + await waitUntilQuiescent(100); + const vatLogs = extractVatLogs(buffered); + expect(vatLogs).toContain( + 'ExoTest: Created temperature converter starting at 100°C', + ); + }, 30000); +}); diff --git a/packages/kernel-test/src/vats/exo-vat.js b/packages/kernel-test/src/vats/exo-vat.js new file mode 100644 index 000000000..84e241e78 --- /dev/null +++ b/packages/kernel-test/src/vats/exo-vat.js @@ -0,0 +1,325 @@ +import { makeScalarMapStore, makeScalarSetStore } from '@agoric/store'; +import { makeExo, defineExoClass, defineExoClassKit } from '@endo/exo'; +import { Far } from '@endo/marshal'; +import { M } from '@endo/patterns'; + +/** + * Build function for testing exo objects and liveslots virtual object functionality. + * + * @param {unknown} _vatPowers - Special powers granted to this vat (not used here). + * @param {unknown} parameters - Initialization parameters from the vat's config object. + * @param {unknown} baggage - Root of vat's persistent state (not used here). + * @returns {unknown} The root object for the new vat. + */ +export function buildRootObject(_vatPowers, parameters, baggage) { + const vatName = parameters?.name ?? 'anonymous'; + + /** + * Print a message to the log. + * + * @param {string} message - The message to print. + */ + function log(message) { + console.log(`${vatName}: ${message}`); + } + + /** + * Print a message to the log, tagged as part of the test output. + * + * @param {string} message - The message to print. + * @param {...any} args - Additional arguments to print. + */ + function tlog(message, ...args) { + if (args.length > 0) { + console.log(`::> ${vatName}: ${message}`, ...args); + } else { + console.log(`::> ${vatName}: ${message}`); + } + } + + log(`buildRootObject`); + + // Create stores for testing + const mapStore = makeScalarMapStore('testMap'); + const setStore = makeScalarSetStore('testSet'); + + // Define interfaces for our Exo objects + const CounterI = M.interface('Counter', { + getValue: M.call().returns(M.number()), + increment: M.call(M.number()).returns(M.number()), + decrement: M.call(M.number()).returns(M.number()), + }); + + const PersonI = M.interface('Person', { + getName: M.call().returns(M.string()), + getAge: M.call().returns(M.number()), + birthday: M.call().returns(M.number()), + addFriend: M.call(M.any()).returns(M.number()), + getFriends: M.call().returns(M.arrayOf(M.any())), + }); + + // Define two facets for a Temperature converter + const CelsiusI = M.interface('Celsius', { + getCelsius: M.call().returns(M.number()), + setCelsius: M.call(M.number()).returns(M.number()), + }); + + const FahrenheitI = M.interface('Fahrenheit', { + getFahrenheit: M.call().returns(M.number()), + setFahrenheit: M.call(M.number()).returns(M.number()), + }); + + // Define a simple Counter exo class + const Counter = defineExoClass( + 'Counter', + CounterI, + (initialValue = 0) => ({ value: initialValue }), + { + getValue() { + return this.state.value; + }, + increment(amount = 1) { + this.state.value += amount; + return this.state.value; + }, + decrement(amount = 1) { + this.state.value -= amount; + return this.state.value; + }, + }, + ); + + // Define a Person exo class with more complex state + const Person = defineExoClass( + 'Person', + PersonI, + (name, age) => ({ name, age, friends: [] }), + { + getName() { + return this.state.name; + }, + getAge() { + return this.state.age; + }, + birthday() { + this.state.age += 1; + return this.state.age; + }, + addFriend(friend) { + this.state.friends.push(friend); + return this.state.friends.length; + }, + getFriends() { + return [...this.state.friends]; + }, + }, + ); + + // Use defineExoClassKit to create a Temperature converter with two facets + const makeTemperatureKit = defineExoClassKit( + 'Temperature', + { celsius: CelsiusI, fahrenheit: FahrenheitI }, + (initialCelsius = 0) => ({ celsius: initialCelsius }), + { + celsius: { + getCelsius() { + return this.state.celsius; + }, + setCelsius(value) { + this.state.celsius = value; + return value; + }, + }, + fahrenheit: { + getFahrenheit() { + return (this.state.celsius * 9) / 5 + 32; + }, + setFahrenheit(value) { + this.state.celsius = ((value - 32) * 5) / 9; + return value; + }, + }, + }, + ); + + // Initialize state if not present + if (baggage.has('initialized')) { + tlog(`state already initialized`); + } else { + baggage.init('initialized', true); + baggage.init('counterValue', 0); + tlog(`initializing state`); + } + + // Create a counter instance stored in baggage + let counterValue = baggage.get('counterValue'); + tlog(`counter value from baggage: ${counterValue}`); + + // Create a direct exo instance using makeExo + const simpleCounter = makeExo('SimpleCounter', CounterI, { + getValue() { + return counterValue; + }, + increment(amount = 1) { + counterValue += amount; + return counterValue; + }, + decrement(amount = 1) { + counterValue -= amount; + return counterValue; + }, + }); + + return Far('root', { + async bootstrap() { + tlog(`bootstrap()`); + + // Test Counter from defineExoClass + const counter = Counter(10); + tlog(`Created counter with initial value: ${counter.getValue()}`); + const newVal = counter.increment(5); + tlog(`Incremented counter by 5 to: ${newVal}`); + + try { + counter.increment(-3); // Should fail due to type constraints + tlog(`ERROR: Increment with negative value should have failed`); + } catch (error) { + tlog( + `Successfully caught error on negative increment: ${error.message}`, + ); + } + + // Test Person from defineExoClass + const alice = Person('Alice', 30); + const bob = Person('Bob', 25); + alice.addFriend(bob); + tlog(`${alice.getName()} has ${alice.getFriends().length} friends`); + + // Test map store + mapStore.init('alice', alice); + mapStore.init('bob', bob); + tlog(`Added ${mapStore.getSize()} entries to map store`); + + // Test set store + setStore.add(alice); + setStore.add(bob); + tlog(`Added ${setStore.getSize()} entries to set store`); + + // Test retrieving from stores + const retrievedAlice = mapStore.get('alice'); + tlog(`Retrieved ${retrievedAlice.getName()} from map store`); + + // Test Temperature from defineExoClassKit + const { celsius, fahrenheit } = makeTemperatureKit(25); + tlog(`Temperature at 25°C = ${fahrenheit.getFahrenheit()}°F`); + + fahrenheit.setFahrenheit(68); + tlog(`After setting to 68°F, celsius is ${celsius.getCelsius()}°C`); + + // Test direct exo object with makeExo + tlog(`SimpleCounter initial value: ${simpleCounter.getValue()}`); + const simpleIncremented = simpleCounter.increment(7); + tlog(`SimpleCounter after +7: ${simpleIncremented}`); + + // Test persistence + counterValue = simpleCounter.getValue(); + baggage.set('counterValue', counterValue); + tlog(`Updated baggage counter to: ${counterValue}`); + + return 'exo-test-complete'; + }, + + createCounter(initialValue = 0) { + const counter = Counter(initialValue); + tlog(`Created new counter with value: ${initialValue}`); + return counter; + }, + + createPerson(name, age) { + const person = Person(name, age); + tlog(`Created person ${name}, age ${age}`); + return person; + }, + + createTemperature(initialCelsius = 0) { + const temperature = makeTemperatureKit(initialCelsius); + tlog(`Created temperature converter starting at ${initialCelsius}°C`); + return temperature; + }, + + createOrUpdateInMap(key, value) { + if (mapStore.has(key)) { + mapStore.set(key, value); + tlog(`Updated ${key} in map`); + } else { + mapStore.init(key, value); + tlog(`Added ${key} to map, size now: ${mapStore.getSize()}`); + } + return mapStore.getSize(); + }, + + getFromMap(key) { + if (mapStore.has(key)) { + tlog(`Found ${key} in map`); + return mapStore.get(key); + } + tlog(`${key} not found in map`); + return null; + }, + + testExoClass() { + const counter = Counter(3); + let result = counter.increment(5); + tlog(`Counter: 3 + 5 = ${result}`); + + result = counter.decrement(2); + tlog(`Counter: 8 - 2 = ${result}`); + + try { + // @ts-expect-error Intentionally passing a string to test validation + counter.increment('foo'); + tlog(`ERROR: Increment with string should have failed`); + } catch (error) { + tlog(`Successfully caught type error: ${error.message}`); + } + + return 'exoClass-tests-complete'; + }, + + testExoClassKit() { + const { celsius, fahrenheit } = makeTemperatureKit(20); + + tlog(`20°C = ${fahrenheit.getFahrenheit()}°F`); + + fahrenheit.setFahrenheit(32); + tlog(`32°F = ${celsius.getCelsius()}°C`); + + // Access between facets should not work + try { + // @ts-expect-error Testing for expected runtime failure + celsius.getFahrenheit(); + tlog(`ERROR: Cross-facet access should have failed`); + } catch (error) { + tlog(`Successfully caught cross-facet error: ${error.message}`); + } + + return 'exoClassKit-tests-complete'; + }, + + testScalarStore() { + // Test map store operations + const person = Person('Charlie', 40); + mapStore.init('charlie', person); + tlog(`Map store size: ${mapStore.getSize()}`); + tlog(`Map store keys: ${[...mapStore.keys()].join(', ')}`); + tlog(`Map has 'charlie': ${mapStore.has('charlie')}`); + + // Test set store operations + setStore.add(person); + tlog(`Set store size: ${setStore.getSize()}`); + tlog(`Set has Charlie: ${setStore.has(person)}`); + + return 'scalar-store-tests-complete'; + }, + }); +} diff --git a/yarn.lock b/yarn.lock index 829a533b6..c3e3d2d42 100644 --- a/yarn.lock +++ b/yarn.lock @@ -89,6 +89,19 @@ __metadata: languageName: node linkType: hard +"@agoric/store@npm:0.9.3-u19.0": + version: 0.9.3-u19.0 + resolution: "@agoric/store@npm:0.9.3-u19.0" + dependencies: + "@endo/errors": "npm:^1.2.9" + "@endo/exo": "npm:^1.5.8" + "@endo/marshal": "npm:^1.6.3" + "@endo/pass-style": "npm:^1.4.8" + "@endo/patterns": "npm:^1.4.8" + checksum: 10/3794e080ab0e17ff6d6645b782884eb62934a3d63e1dee325b66e1dd06003f03f78244338ad08c8f961fb15bb8cc042f5bc4e439c0af7411b778fc546e95d2d8 + languageName: node + linkType: hard + "@agoric/store@npm:0.9.3-upgrade-19-dev-aa5fa27.0+aa5fa27": version: 0.9.3-upgrade-19-dev-aa5fa27.0 resolution: "@agoric/store@npm:0.9.3-upgrade-19-dev-aa5fa27.0" @@ -633,18 +646,18 @@ __metadata: languageName: node linkType: hard -"@endo/exo@npm:^1.5.8": - version: 1.5.8 - resolution: "@endo/exo@npm:1.5.8" +"@endo/exo@npm:^1.5.8, @endo/exo@npm:^1.5.9": + version: 1.5.9 + resolution: "@endo/exo@npm:1.5.9" dependencies: - "@endo/common": "npm:^1.2.9" + "@endo/common": "npm:^1.2.10" "@endo/env-options": "npm:^1.1.8" - "@endo/errors": "npm:^1.2.9" - "@endo/eventual-send": "npm:^1.3.0" - "@endo/far": "npm:^1.1.10" - "@endo/pass-style": "npm:^1.4.8" - "@endo/patterns": "npm:^1.4.8" - checksum: 10/72c798344b9c0b1d008b8131210563a9919bb7e4b489700cf8147001e6842f8e5d631b9e53bbb3a93aca14b3fe11dc8740d32537528caf890f2e349c749ac943 + "@endo/errors": "npm:^1.2.10" + "@endo/eventual-send": "npm:^1.3.1" + "@endo/far": "npm:^1.1.11" + "@endo/pass-style": "npm:^1.5.0" + "@endo/patterns": "npm:^1.5.0" + checksum: 10/529338075e15b42bbf6611b0be99087f12ff91c857e5cdc59073ac241559829f6d620eab142d3bafda36d5b46225b6d23c4f68697a12b42da28ed3ddbd94f57e languageName: node linkType: hard @@ -739,16 +752,17 @@ __metadata: languageName: node linkType: hard -"@endo/patterns@npm:^1.4.7, @endo/patterns@npm:^1.4.8": - version: 1.4.8 - resolution: "@endo/patterns@npm:1.4.8" +"@endo/patterns@npm:^1.4.7, @endo/patterns@npm:^1.4.8, @endo/patterns@npm:^1.5.0": + version: 1.5.0 + resolution: "@endo/patterns@npm:1.5.0" dependencies: - "@endo/common": "npm:^1.2.9" - "@endo/errors": "npm:^1.2.9" - "@endo/eventual-send": "npm:^1.3.0" - "@endo/marshal": "npm:^1.6.3" - "@endo/promise-kit": "npm:^1.1.9" - checksum: 10/8d547e8872754ea535df6a0e53dee66075bd95adc5b875bd553c6e34ce1fcf3c8c0bbfa67d88fcb265f2ae5660ec7a10d194ff738c2c261e51de3f630cbab353 + "@endo/common": "npm:^1.2.10" + "@endo/errors": "npm:^1.2.10" + "@endo/eventual-send": "npm:^1.3.1" + "@endo/marshal": "npm:^1.6.4" + "@endo/pass-style": "npm:^1.5.0" + "@endo/promise-kit": "npm:^1.1.10" + checksum: 10/4cb54c0d844586dc7fde85a7e7cf409ef332aea056b850f6449e1f1c503e75b4b8bb5222bb983f2aa705d9d16a93d51cc781c9a5b2b64e92a70327a891645277 languageName: node linkType: hard @@ -2006,9 +2020,12 @@ __metadata: version: 0.0.0-use.local resolution: "@ocap/kernel-test@workspace:packages/kernel-test" dependencies: + "@agoric/store": "npm:0.9.3-u19.0" "@arethetypeswrong/cli": "npm:^0.17.4" "@endo/eventual-send": "npm:^1.3.1" + "@endo/exo": "npm:^1.5.9" "@endo/marshal": "npm:^1.6.4" + "@endo/patterns": "npm:^1.5.0" "@endo/promise-kit": "npm:^1.1.10" "@metamask/auto-changelog": "npm:^5.0.1" "@metamask/eslint-config": "npm:^14.0.0"