From 483c9deaefec4855c2b1df40c8d4072e15691a95 Mon Sep 17 00:00:00 2001 From: NathanosDev Date: Mon, 2 Feb 2026 21:56:27 +0100 Subject: [PATCH 1/8] feat: add http gateway support --- .node-version | 2 +- Cargo.lock | 129 ++++++++++++++++++ Cargo.toml | 7 +- dfx.json | 14 +- .../src/content/docs/guides/more-examples.mdx | 1 + examples/README.md | 4 +- examples/http/Cargo.toml | 13 ++ examples/http/README.md | 15 ++ examples/http/http.did | 18 +++ examples/http/src/index.html | 11 ++ examples/http/src/lib.rs | 53 +++++++ examples/http/tests/global-setup.ts | 9 ++ examples/http/tests/global-teardown.ts | 3 + examples/http/tests/jest.config.ts | 14 ++ examples/http/tests/package.json | 6 + examples/http/tests/src/http.spec.ts | 45 ++++++ examples/http/tests/tsconfig.json | 13 ++ examples/http/tests/types.d.ts | 11 ++ examples/todo/README.md | 2 +- examples/todo/tests/src/live-todo.spec.ts | 110 +++++++++++++++ package.json | 6 +- packages/pic/src/http2-client.ts | 17 ++- packages/pic/src/pocket-ic-client-types.ts | 28 ++++ packages/pic/src/pocket-ic-client.ts | 49 ++++++- packages/pic/src/pocket-ic.ts | 101 +++++++++++++- pnpm-workspace.yaml | 1 + 26 files changed, 669 insertions(+), 13 deletions(-) create mode 100644 examples/http/Cargo.toml create mode 100644 examples/http/README.md create mode 100644 examples/http/http.did create mode 100644 examples/http/src/index.html create mode 100644 examples/http/src/lib.rs create mode 100644 examples/http/tests/global-setup.ts create mode 100644 examples/http/tests/global-teardown.ts create mode 100644 examples/http/tests/jest.config.ts create mode 100644 examples/http/tests/package.json create mode 100644 examples/http/tests/src/http.spec.ts create mode 100644 examples/http/tests/tsconfig.json create mode 100644 examples/http/tests/types.d.ts create mode 100644 examples/todo/tests/src/live-todo.spec.ts diff --git a/.node-version b/.node-version index 0a49261..3fe3b15 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -24.11.0 +24.13.0 diff --git a/Cargo.lock b/Cargo.lock index 5ce261d..a6d10f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -50,6 +50,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23ce669cd6c8588f79e15cf450314f9638f967fc5770ff1c7c1deb0925ea7cfa" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "beef" version = "0.5.2" @@ -109,12 +115,28 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + [[package]] name = "candid" version = "0.10.19" @@ -413,6 +435,25 @@ dependencies = [ "wasi", ] +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "half" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b43ede17f21864e81be2fa654110bf1e793774238d86ef8555c37e6519c0403" + [[package]] name = "handlebars" version = "6.3.2" @@ -450,6 +491,39 @@ dependencies = [ "serde", ] +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-canister" +version = "0.1.0" +dependencies = [ + "candid", + "ic-asset-certification", + "ic-cdk", + "ic-http-certification", +] + +[[package]] +name = "ic-asset-certification" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c43805022a5e4f408de44ca26396128697a2c39f83f4fdaf33f8aa3ac653d78e" +dependencies = [ + "globset", + "http", + "ic-certification", + "ic-http-certification", + "thiserror 1.0.69", +] + [[package]] name = "ic-cdk" version = "0.18.7" @@ -502,6 +576,18 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "ic-certification" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffb40d73f9f8273dc6569a68859003bbd467c9dc6d53c6fd7d174742f857209d" +dependencies = [ + "hex", + "serde", + "serde_bytes", + "sha2", +] + [[package]] name = "ic-error-types" version = "0.2.0" @@ -513,6 +599,23 @@ dependencies = [ "strum_macros", ] +[[package]] +name = "ic-http-certification" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d1a65b0ffb568e954750067f660e254f4564394f5c064a88e0e93b2eea4a532" +dependencies = [ + "base64", + "candid", + "http", + "ic-certification", + "ic-representation-independent-hash", + "serde", + "serde_cbor", + "thiserror 1.0.69", + "urlencoding", +] + [[package]] name = "ic-management-canister-types" version = "0.3.3" @@ -524,6 +627,16 @@ dependencies = [ "serde_bytes", ] +[[package]] +name = "ic-representation-independent-hash" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2800ba4910f21d9e1cb7b6ecbbbb0f76074bd2e127b4688c57d0936206caa6e" +dependencies = [ + "leb128", + "sha2", +] + [[package]] name = "ic-stable-structures" version = "0.6.9" @@ -1101,6 +1214,16 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_cbor" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bef2ebfde456fb76bbcf9f59315333decc4fda0b2b44b420243c11e0f5ec1f5" +dependencies = [ + "half", + "serde", +] + [[package]] name = "serde_core" version = "1.0.225" @@ -1441,6 +1564,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "version_check" version = "0.9.5" diff --git a/Cargo.toml b/Cargo.toml index 8ba562e..b78a18a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,8 @@ [workspace] -members = ["examples/todo", "examples/nns_proxy", "examples/icp_features"] +members = [ + "examples/http", + "examples/icp_features", + "examples/nns_proxy", + "examples/todo", +] resolver = "2" diff --git a/dfx.json b/dfx.json index aa12ab9..9ca6052 100644 --- a/dfx.json +++ b/dfx.json @@ -1,6 +1,6 @@ { "version": 1, - "dfx": "0.29.1", + "dfx": "0.30.2", "output_env_file": ".env", "networks": { "local": { @@ -113,6 +113,18 @@ }, "gzip": true, "optimize": "cycles" + }, + "http": { + "type": "rust", + "candid": "examples/http/http.did", + "package": "http-canister", + "frontend": {}, + "declarations": { + "bindings": ["js", "ts"], + "output": "examples/http/declarations" + }, + "gzip": true, + "optimize": "cycles" } } } diff --git a/docs/src/content/docs/guides/more-examples.mdx b/docs/src/content/docs/guides/more-examples.mdx index 66c679b..0f49ce0 100644 --- a/docs/src/content/docs/guides/more-examples.mdx +++ b/docs/src/content/docs/guides/more-examples.mdx @@ -15,3 +15,4 @@ but `@dfinity/pic` can be used with JavaScript and any other testing runner, suc - The [NNS Proxy](https://github.com/dfinity/pic-js/tree/main/examples/nns_proxy) example demonstrates how to work with an NNS state directory. - [Google Search](https://github.com/dfinity/pic-js/tree/main/examples/google_search) example demonstrates how to mock HTTPS Outcalls. - [ICP Features](https://github.com/dfinity/pic-js/tree/main/examples/icp_features) example demonstrates how to work with PocketIC's ICP features. +- [HTTP](https://github.com/dfinity/pic-js/tree/main/examples/http) example demonstrates how to use "live" mode with an HTTP canister. diff --git a/examples/README.md b/examples/README.md index c303b6a..90fdd4b 100644 --- a/examples/README.md +++ b/examples/README.md @@ -18,10 +18,12 @@ but `@dfinity/pic` can be used with JavaScript and any other testing runner, suc - [Clock](./clock/README.md) This example demonstrates how to work with the replica's system time, canister timers as well as checking for canister existence and cycle management. - [Todo](./todo/README.md) - This example demonstrates how to work with more complex canisters, identities, canister upgrades, and stable memory management. + This example demonstrates how to work with more complex canisters, identities, canister upgrades, and stable memory management. It also shows how to use "live" mode with agent-js (in contrast to the pic-js actor). - [Multicanister](./multicanister/README.md) This example demonstrates how to work with multiple canisters and multiple subnets. - [NNS Proxy](./nns_proxy/README.md) This example demonstrates how to work with an NNS state directory. - [Google Search](./google_search/README.md) This example demonstrates how to mock HTTPS Outcalls. +- [HTTP](./http/README.md) + This example demonstrates how to use "live" mode with an HTTP canister. diff --git a/examples/http/Cargo.toml b/examples/http/Cargo.toml new file mode 100644 index 0000000..dc1f134 --- /dev/null +++ b/examples/http/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "http-canister" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +candid = "0.10" +ic-cdk = "0.18" +ic-http-certification = "3" +ic-asset-certification = "3" diff --git a/examples/http/README.md b/examples/http/README.md new file mode 100644 index 0000000..be2a298 --- /dev/null +++ b/examples/http/README.md @@ -0,0 +1,15 @@ +# HTTP + +This example demonstrates how to use "live" mode with an HTTP canister. + +Build the HTTP canister: + +```shell +bun build:http +``` + +Run the HTTP tests: + +```shell +bun test:http +``` diff --git a/examples/http/http.did b/examples/http/http.did new file mode 100644 index 0000000..a7072fe --- /dev/null +++ b/examples/http/http.did @@ -0,0 +1,18 @@ +type HttpRequest = record { + url : text; + method : text; + body : blob; + headers : vec record { text; text }; + certificate_version : opt nat16; +}; + +type HttpResponse = record { + body : blob; + headers : vec record { text; text }; + upgrade : opt bool; + status_code : nat16; +}; + +service : { + http_request : (HttpRequest) -> (HttpResponse) query; +}; diff --git a/examples/http/src/index.html b/examples/http/src/index.html new file mode 100644 index 0000000..06c94f5 --- /dev/null +++ b/examples/http/src/index.html @@ -0,0 +1,11 @@ + + + + + + HTTP Canister + + +

Hello, World!

+ + diff --git a/examples/http/src/lib.rs b/examples/http/src/lib.rs new file mode 100644 index 0000000..90fba5f --- /dev/null +++ b/examples/http/src/lib.rs @@ -0,0 +1,53 @@ +use ic_asset_certification::{Asset, AssetConfig, AssetRouter}; +use ic_cdk::{ + api::{certified_data_set, data_certificate}, + *, +}; +use ic_http_certification::{HttpRequest, HttpResponse}; +use std::cell::RefCell; + +thread_local! { + static ASSET_ROUTER: RefCell> = Default::default(); +} + +const INDEX_HTML: &[u8] = include_bytes!("index.html"); + +#[init] +fn init() { + let assets = vec![Asset::new("index.html", INDEX_HTML)]; + let asset_configs = vec![AssetConfig::File { + path: "index.html".to_string(), + content_type: Some("text/html".to_string()), + headers: vec![], + fallback_for: vec![], + aliased_by: vec![], + encodings: vec![], + }]; + + ASSET_ROUTER.with_borrow_mut(|asset_router| { + if let Err(err) = asset_router.certify_assets(assets, asset_configs) { + ic_cdk::trap(&format!("Failed to certify assets: {}", err)); + } + + certified_data_set(&asset_router.root_hash()); + }); +} + +#[post_upgrade] +fn post_upgrade() { + init(); +} + +#[query] +fn http_request(req: HttpRequest) -> HttpResponse { + ASSET_ROUTER.with_borrow(|asset_router| { + if let Ok(response) = asset_router.serve_asset( + &data_certificate().expect("No data certificate available"), + &req, + ) { + response + } else { + ic_cdk::trap("Failed to serve asset"); + } + }) +} diff --git a/examples/http/tests/global-setup.ts b/examples/http/tests/global-setup.ts new file mode 100644 index 0000000..308abf9 --- /dev/null +++ b/examples/http/tests/global-setup.ts @@ -0,0 +1,9 @@ +import { PocketIcServer } from '@dfinity/pic'; + +module.exports = async function (): Promise { + const pic = await PocketIcServer.start(); + const url = pic.getUrl(); + + process.env.PIC_URL = url; + global.__PIC__ = pic; +}; diff --git a/examples/http/tests/global-teardown.ts b/examples/http/tests/global-teardown.ts new file mode 100644 index 0000000..487cdff --- /dev/null +++ b/examples/http/tests/global-teardown.ts @@ -0,0 +1,3 @@ +module.exports = async function () { + await global.__PIC__.stop(); +}; diff --git a/examples/http/tests/jest.config.ts b/examples/http/tests/jest.config.ts new file mode 100644 index 0000000..fcbdac3 --- /dev/null +++ b/examples/http/tests/jest.config.ts @@ -0,0 +1,14 @@ +import type { Config } from 'jest'; + +const config: Config = { + watch: false, + transform: { + '^.+\\.(t|j)sx?$': '@swc/jest', + }, + testEnvironment: 'node', + globalSetup: '/global-setup.ts', + globalTeardown: '/global-teardown.ts', + testTimeout: 120_000, +}; + +export default config; diff --git a/examples/http/tests/package.json b/examples/http/tests/package.json new file mode 100644 index 0000000..5b7dd6e --- /dev/null +++ b/examples/http/tests/package.json @@ -0,0 +1,6 @@ +{ + "name": "http-tests", + "devDependencies": { + "@dfinity/pic": "workspace:*" + } +} diff --git a/examples/http/tests/src/http.spec.ts b/examples/http/tests/src/http.spec.ts new file mode 100644 index 0000000..200ed8f --- /dev/null +++ b/examples/http/tests/src/http.spec.ts @@ -0,0 +1,45 @@ +import { resolve } from 'node:path'; +import { PocketIc, SubnetStateType } from '@dfinity/pic'; +import { _SERVICE } from '../../declarations/http.did'; + +const WASM_PATH = resolve( + __dirname, + '..', + '..', + '..', + '..', + '.dfx', + 'local', + 'canisters', + 'http', + 'http.wasm.gz', +); + +describe('HTTP', () => { + let pic: PocketIc; + let httpGatewayUrl: string; + + beforeEach(async () => { + pic = await PocketIc.create(process.env.PIC_URL, { + nns: { state: { type: SubnetStateType.New } }, + application: [{ state: { type: SubnetStateType.New } }], + }); + + const canisterId = await pic.createCanister(); + await pic.installCode({ canisterId, wasm: WASM_PATH }); + + const httpGatewayPort = await pic.makeLive(); + httpGatewayUrl = `http://${canisterId}.localhost:${httpGatewayPort}`; + }); + + afterEach(async () => { + await pic.stopLive(); + await pic.tearDown(); + }); + + it('should return an index.html page', async () => { + const res = await fetch(`${httpGatewayUrl}/index.html`); + const resBody = await res.text(); + expect(resBody).toContain('

Hello, World!

'); + }); +}); diff --git a/examples/http/tests/tsconfig.json b/examples/http/tests/tsconfig.json new file mode 100644 index 0000000..b09bcf8 --- /dev/null +++ b/examples/http/tests/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "allowJs": true, + "types": ["jest", "node"] + }, + "include": [ + "./src/**/*.ts", + "./global-setup.ts", + "./global-teardown.ts", + "./types.d.ts" + ] +} diff --git a/examples/http/tests/types.d.ts b/examples/http/tests/types.d.ts new file mode 100644 index 0000000..80781fe --- /dev/null +++ b/examples/http/tests/types.d.ts @@ -0,0 +1,11 @@ +import { PocketIcServer } from '@dfinity/pic'; + +declare global { + declare var __PIC__: PocketIcServer; + + namespace NodeJS { + interface ProcessEnv { + PIC_URL: string; + } + } +} diff --git a/examples/todo/README.md b/examples/todo/README.md index fef888e..dfacfb3 100644 --- a/examples/todo/README.md +++ b/examples/todo/README.md @@ -1,6 +1,6 @@ # Todo -This example demonstrates how to work with more complex canisters, identities, canister upgrades, and stable memory management. +This example demonstrates how to work with more complex canisters, identities, canister upgrades, and stable memory management. It also shows how to use "live" mode with agent-js (in contrast to the pic-js actor). Build the counter canister: diff --git a/examples/todo/tests/src/live-todo.spec.ts b/examples/todo/tests/src/live-todo.spec.ts new file mode 100644 index 0000000..899d2d5 --- /dev/null +++ b/examples/todo/tests/src/live-todo.spec.ts @@ -0,0 +1,110 @@ +import { resolve } from 'node:path'; +import { ActorSubclass, Actor, HttpAgent } from '@icp-sdk/core/agent'; +import { PocketIc, SubnetStateType, createIdentity } from '@dfinity/pic'; +import { _SERVICE, idlFactory } from '../../declarations/todo.did'; + +const WASM_PATH = resolve( + __dirname, + '..', + '..', + '..', + '..', + '.dfx', + 'local', + 'canisters', + 'todo', + 'todo.wasm.gz', +); + +describe('Live Todo', () => { + let pic: PocketIc; + let agent: HttpAgent; + let actor: ActorSubclass<_SERVICE>; + + const alice = createIdentity('superSecretAlicePassword'); + const bob = createIdentity('superSecretBobPassword'); + + beforeEach(async () => { + pic = await PocketIc.create(process.env.PIC_URL, { + nns: { state: { type: SubnetStateType.New } }, + application: [{ state: { type: SubnetStateType.New } }], + }); + + const canisterId = await pic.createCanister(); + await pic.installCode({ canisterId, wasm: WASM_PATH }); + + const httpGatewayPort = await pic.makeLive(); + const httpGatewayUrl = `http://localhost:${httpGatewayPort}`; + agent = await HttpAgent.create({ + host: httpGatewayUrl, + shouldFetchRootKey: true, + }); + actor = Actor.createActor(idlFactory, { agent, canisterId }); + }); + + afterEach(async () => { + await pic.stopLive(); + await pic.tearDown(); + }); + + it("should return alice's todos to alice and bob's todos to bob", async () => { + agent.replaceIdentity(alice); + const aliceCreateResponse = await actor.create_todo({ + text: 'Learn Rust', + }); + const aliceAfterCreateGetResponse = await actor.get_todos(); + + agent.replaceIdentity(bob); + const bobCreateResponse = await actor.create_todo({ + text: 'Learn WebAssembly', + }); + const bobAfterCreateGetResponse = await actor.get_todos(); + + expect(aliceAfterCreateGetResponse.todos).toHaveLength(1); + expect(aliceAfterCreateGetResponse.todos).toContainEqual({ + id: aliceCreateResponse.id, + text: 'Learn Rust', + done: false, + }); + + expect(bobAfterCreateGetResponse.todos).toHaveLength(1); + expect(bobAfterCreateGetResponse.todos).toContainEqual({ + id: bobCreateResponse.id, + text: 'Learn WebAssembly', + done: false, + }); + }); + + it("should prevent bob from updating alice's todo", async () => { + agent.replaceIdentity(alice); + const aliceCreateResponse = await actor.create_todo({ + text: 'Learn Rust', + }); + + agent.replaceIdentity(bob); + await expect( + actor.update_todo(aliceCreateResponse.id, { + text: ['Learn Rust and WebAssembly'], + done: [], + }), + ).rejects.toThrow( + `Caller with principal ${bob + .getPrincipal() + .toText()} does not own todo with id ${aliceCreateResponse.id}`, + ); + }); + + it('should prevent bob from deleting alices todo', async () => { + agent.replaceIdentity(alice); + const aliceCreateResponse = await actor.create_todo({ + text: 'Learn Rust', + }); + + agent.replaceIdentity(bob); + await expect(actor.delete_todo(aliceCreateResponse.id)).rejects.toThrow( + `Caller with principal ${bob + .getPrincipal() + .toText()} does not own todo with id ${aliceCreateResponse.id}`, + ); + }); +}); diff --git a/package.json b/package.json index 32544c8..49586ab 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "npm": "please use pnpm" }, "type": "module", - "packageManager": "pnpm@10.19.0+sha512.c9fc7236e92adf5c8af42fd5bf1612df99c2ceb62f27047032f4720b33f8eacdde311865e91c411f2774f618d82f320808ecb51718bfa82c060c4ba7c76a32b8", + "packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264", "workspaces": [ "examples/clock/tests", "examples/counter/tests", @@ -28,7 +28,7 @@ "test:pic": "jest -c ./packages/pic/jest.config.ts", "build:test-canister": "dfx generate test_canister && dfx build test_canister --check && mv .dfx/local/canisters/test_canister/test_canister.wasm.gz packages/pic/tests/test-canister", "build:examples": "dfx generate phonebook && dfx generate superheroes && dfx generate && dfx build --all --check", - "test:examples": "run-p test:clock test:counter test:google-search test:icp-features test:multicanister test:nns-proxy test:todo", + "test:examples": "run-p test:clock test:counter test:google-search test:icp-features test:multicanister test:nns-proxy test:todo test:http", "build:clock": "dfx generate clock && dfx build clock --check", "test:clock": "jest -c ./examples/clock/tests/jest.config.ts", "build:counter": "dfx generate counter && dfx build counter --check", @@ -43,6 +43,8 @@ "test:nns-proxy": "jest -c ./examples/nns_proxy/tests/jest.config.ts", "build:todo": "dfx generate todo && dfx build todo --check", "test:todo": "jest -c ./examples/todo/tests/jest.config.ts", + "build:http": "dfx generate http && dfx build http --check", + "test:http": "jest -c ./examples/http/tests/jest.config.ts", "postinstall": "tar -xvf examples/nns_proxy/tests/state/nns_state.tar.xz -C examples/nns_proxy/tests/state" }, "devDependencies": { diff --git a/packages/pic/src/http2-client.ts b/packages/pic/src/http2-client.ts index 249de19..cdd258c 100644 --- a/packages/pic/src/http2-client.ts +++ b/packages/pic/src/http2-client.ts @@ -89,6 +89,10 @@ export class Http2Client { return resBody; } + if (typeof resBody === 'boolean') { + return resBody; + } + // server encountered an error, throw and try again if ('message' in resBody) { console.error( @@ -208,15 +212,20 @@ async function getResBody( jsonParser: typeof JSON.parse, ): Promise> { const resBody = await res.text(); + if (!resBody.length) { + return null as unknown as R; + } + try { return jsonParser(resBody) as ApiResponse; } catch (error) { - const message = resBody; - console.error('Error parsing PocketIC server response body:', error); - console.error('Original body:', message); + if (resBody.length < 10_024) { + console.error('Original body:', resBody); + throw new Error(resBody); + } - throw new Error(message); + throw new Error(String(error)); } } diff --git a/packages/pic/src/pocket-ic-client-types.ts b/packages/pic/src/pocket-ic-client-types.ts index cace287..8042675 100644 --- a/packages/pic/src/pocket-ic-client-types.ts +++ b/packages/pic/src/pocket-ic-client-types.ts @@ -1164,3 +1164,31 @@ export function encodeAwaitCanisterCallRequest( export type AwaitCanisterCallResponse = CanisterCallResponse; //#endregion AwaitCanisterCall + +//#region LiveMode +export type EncodedAutoProgressRequest = { + artificial_delay_ms?: number; +}; + +export type EncodedHttpGatewayRequest = { + ip_addr?: string; + port?: number; + forward_to: { Replica: string } | { PocketIcInstance: number }; + domains?: string[]; + https_config?: { + cert_path: string; + key_path: string; + }; +}; + +export type EncodedHttpGatewayResponse = + | { + Error: { message: string }; + } + | { + Created: { + instance_id: number; + port: number; + }; + }; +//#endregion LiveMode diff --git a/packages/pic/src/pocket-ic-client.ts b/packages/pic/src/pocket-ic-client.ts index 7b08f2b..f3cdb9c 100644 --- a/packages/pic/src/pocket-ic-client.ts +++ b/packages/pic/src/pocket-ic-client.ts @@ -81,8 +81,11 @@ import { GetControllersResponse, decodeGetControllersResponse, encodeGetControllersRequest, + EncodedAutoProgressRequest, + EncodedHttpGatewayRequest, + EncodedHttpGatewayResponse, } from './pocket-ic-client-types'; -import { base64DecodePrincipal, isNotNil } from './util'; +import { base64DecodePrincipal, isNil, isNotNil } from './util'; import { Principal } from '@icp-sdk/core/principal'; const PROCESSING_TIME_VALUE_MS = 30_000; @@ -90,10 +93,12 @@ const AWAIT_INGRESS_STATUS_ROUNDS = 100; export class PocketIcClient { private isInstanceDeleted = false; + private httpGatewayInstanceId: number | null = null; private constructor( private readonly serverClient: Http2Client, private readonly instancePath: string, + private readonly instanceId: number, private readonly ingressMaxRetries: number, ) {} @@ -127,6 +132,7 @@ export class PocketIcClient { return new PocketIcClient( serverClient, `/instances/${instanceId}`, + instanceId, ingressMaxRetries, ); } @@ -382,6 +388,47 @@ export class PocketIcClient { ); } + public async autoProgress(): Promise { + await this.post('/auto_progress', { + artificial_delay_ms: 0, + }); + } + + public async autoProgressEnabled(): Promise { + return await this.get('/auto_progress'); + } + + public async stopProgress(): Promise { + await this.post<{}, {}>('/stop_progress', {}); + } + + public async startHttpGateway(): Promise { + const res = await this.serverClient.jsonPost< + EncodedHttpGatewayRequest, + EncodedHttpGatewayResponse + >({ + path: '/http_gateway', + body: { forward_to: { PocketIcInstance: this.instanceId } }, + }); + + if ('Error' in res) { + throw new Error(res.Error.message); + } + + return res.Created.port; + } + + public async stopHttpGateway(): Promise { + if (isNil(this.httpGatewayInstanceId)) { + return; + } + + this.httpGatewayInstanceId = null; + await this.serverClient.jsonPost<{}, {}>({ + path: `/http_gateway/${this.httpGatewayInstanceId}/stop`, + }); + } + private async post( endpoint: string, body?: B, diff --git a/packages/pic/src/pocket-ic.ts b/packages/pic/src/pocket-ic.ts index 8b1a94c..7a0fe87 100644 --- a/packages/pic/src/pocket-ic.ts +++ b/packages/pic/src/pocket-ic.ts @@ -1,6 +1,6 @@ import { Principal } from '@icp-sdk/core/principal'; import { IDL } from '@icp-sdk/core/candid'; -import { optional, readFileAsBytes } from './util'; +import { isNil, optional, readFileAsBytes } from './util'; import { PocketIcClient } from './pocket-ic-client'; import { ActorInterface, Actor, createActorClass } from './pocket-ic-actor'; import { @@ -85,6 +85,8 @@ const NANOS_PER_MILLISECOND = BigInt(1_000_000); * ``` */ export class PocketIc { + private httpGatewayPort: number | null = null; + private constructor(private readonly client: PocketIcClient) {} /** @@ -1475,4 +1477,101 @@ export class PocketIc { additionalResponses, }); } + + /** + * Make the PocketIC instance live by enabling auto progress and starting the HTTP Gateway. + * If the server is already live, this method will return the HTTP Gateway URL. + * The PocketIC instance must be created with at least an NNS subnet in + * order for `fetchRootKey` to work correctly. + * + * @example + * ```ts + * import { Principal } from '@icp-sdk/core/principal'; + * import { PocketIc, PocketIcServer } from '@dfinity/pic'; + * import { resolve } from 'node:path'; + * + * const canisterId = Principal.fromUint8Array(new Uint8Array([0])); + * const wasm = resolve('..', '..', 'canister.wasm'); + * + * const picServer = await PocketIcServer.start(); + * const pic = await PocketIc.create(picServer.getUrl(), { + * nns: { state: { type: SubnetStateType.New } }, + * application: [{ state: { type: SubnetStateType.New } }], + * }); + * + * const canister = await pic.installCode({ canisterId, wasm }); + * await pic.installCode({ canisterId, wasm }); + * + * const httpGatewayUrl = await pic.makeLive(); + * const agent = await HttpAgent.create({ + * host: httpGatewayUrl, + * shouldFetchRootKey: true, + * }); + * const actor = Actor.createActor(idlFactory, { agent, canisterId }); + * + * await pic.stopLive(); + * await pic.tearDown(); + * await picServer.stop(); + * ``` + * + * @returns The HTTP Gateway port. + */ + public async makeLive(): Promise { + const isLive = await this.client.autoProgressEnabled(); + if (isLive) { + if (isNil(this.httpGatewayPort)) { + throw new Error( + 'Inconsistent state, PocketIC server is live but no HTTP Gateway URL is known', + ); + } + + return this.httpGatewayPort; + } + + await this.client.autoProgress(); + this.httpGatewayPort = await this.client.startHttpGateway(); + + return this.httpGatewayPort; + } + + /** + * Disables auto progress and stops the HTTP Gateway for the PocketIC instance. + * + * + * @example + * ```ts + * import { Principal } from '@icp-sdk/core/principal'; + * import { PocketIc, PocketIcServer } from '@dfinity/pic'; + * import { resolve } from 'node:path'; + * + * const canisterId = Principal.fromUint8Array(new Uint8Array([0])); + * const wasm = resolve('..', '..', 'canister.wasm'); + * + * const picServer = await PocketIcServer.start(); + * const pic = await PocketIc.create(picServer.getUrl(), { + * nns: { state: { type: SubnetStateType.New } }, + * application: [{ state: { type: SubnetStateType.New } }], + * }); + * + * const canister = await pic.installCode({ canisterId, wasm }); + * await pic.installCode({ canisterId, wasm }); + * + * const httpGatewayUrl = await pic.makeLive(); + * const agent = await HttpAgent.create({ + * host: httpGatewayUrl, + * shouldFetchRootKey: true, + * }); + * const actor = Actor.createActor(idlFactory, { agent, canisterId }); + * + * await pic.stopLive(); + * await pic.tearDown(); + * await picServer.stop(); + * ``` + */ + public async stopLive(): Promise { + this.httpGatewayPort = null; + + await this.client.stopHttpGateway(); + await this.client.stopProgress(); + } } diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8d0e252..4bded22 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,7 @@ packages: - examples/clock/tests - examples/counter/tests - examples/google_search/tests + - examples/http/tests - examples/icp_features/tests - examples/multicanister/tests - examples/nns_proxy/tests From 29e30d983940e3efd38d840305de47d765b65bb5 Mon Sep 17 00:00:00 2001 From: NathanosDev Date: Mon, 2 Feb 2026 22:01:38 +0100 Subject: [PATCH 2/8] copilot feedback --- packages/pic/src/http2-client.ts | 2 +- packages/pic/src/pocket-ic-client.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/pic/src/http2-client.ts b/packages/pic/src/http2-client.ts index cdd258c..25223f9 100644 --- a/packages/pic/src/http2-client.ts +++ b/packages/pic/src/http2-client.ts @@ -220,7 +220,7 @@ async function getResBody( return jsonParser(resBody) as ApiResponse; } catch (error) { console.error('Error parsing PocketIC server response body:', error); - if (resBody.length < 10_024) { + if (resBody.length < 10_240) { console.error('Original body:', resBody); throw new Error(resBody); } diff --git a/packages/pic/src/pocket-ic-client.ts b/packages/pic/src/pocket-ic-client.ts index f3cdb9c..6573919 100644 --- a/packages/pic/src/pocket-ic-client.ts +++ b/packages/pic/src/pocket-ic-client.ts @@ -415,6 +415,7 @@ export class PocketIcClient { throw new Error(res.Error.message); } + this.httpGatewayInstanceId = res.Created.instance_id; return res.Created.port; } @@ -423,10 +424,10 @@ export class PocketIcClient { return; } - this.httpGatewayInstanceId = null; await this.serverClient.jsonPost<{}, {}>({ path: `/http_gateway/${this.httpGatewayInstanceId}/stop`, }); + this.httpGatewayInstanceId = null; } private async post( From cf031217536ab45e47a065024e5b1a5650c83744 Mon Sep 17 00:00:00 2001 From: NathanosDev Date: Tue, 3 Feb 2026 19:31:58 +0100 Subject: [PATCH 3/8] fix: update outdated lock files --- bun.lock | 3 ++- pnpm-lock.yaml | 29 +++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/bun.lock b/bun.lock index c6b9248..8928c74 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "pic-js", "devDependencies": { + "@icp-sdk/core": "^5.0.0", "@swc/core": "^1.15.3", "@swc/jest": "^0.2.39", "@tsconfig/node24": "^24.0.3", @@ -72,7 +73,7 @@ }, "packages/pic": { "name": "@dfinity/pic", - "version": "0.17.1", + "version": "0.18.0", "dependencies": { "@icp-sdk/core": "^5.0.0", "bip39": "^3.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a198575..74029e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,6 +86,12 @@ importers: specifier: workspace:* version: link:../../../packages/pic + examples/http/tests: + devDependencies: + '@dfinity/pic': + specifier: workspace:* + version: link:../../../packages/pic + examples/icp_features/tests: devDependencies: '@dfinity/pic': @@ -674,56 +680,67 @@ packages: resolution: {integrity: sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.53.3': resolution: {integrity: sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.53.3': resolution: {integrity: sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.53.3': resolution: {integrity: sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.53.3': resolution: {integrity: sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.53.3': resolution: {integrity: sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.53.3': resolution: {integrity: sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.53.3': resolution: {integrity: sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.53.3': resolution: {integrity: sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.53.3': resolution: {integrity: sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.53.3': resolution: {integrity: sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openharmony-arm64@4.53.3': resolution: {integrity: sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==} @@ -812,24 +829,28 @@ packages: engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [glibc] '@swc/core-linux-arm64-musl@1.15.3': resolution: {integrity: sha512-j4SJniZ/qaZ5g8op+p1G9K1z22s/EYGg1UXIb3+Cg4nsxEpF5uSIGEE4mHUfA70L0BR9wKT2QF/zv3vkhfpX4g==} engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [musl] '@swc/core-linux-x64-gnu@1.15.3': resolution: {integrity: sha512-aKttAZnz8YB1VJwPQZtyU8Uk0BfMP63iDMkvjhJzRZVgySmqt/apWSdnoIcZlUoGheBrcqbMC17GGUmur7OT5A==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [glibc] '@swc/core-linux-x64-musl@1.15.3': resolution: {integrity: sha512-oe8FctPu1gnUsdtGJRO2rvOUIkkIIaHqsO9xxN0bTR7dFTlPTGi2Fhk1tnvXeyAvCPxLIcwD8phzKg6wLv9yug==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [musl] '@swc/core-win32-arm64-msvc@1.15.3': resolution: {integrity: sha512-L9AjzP2ZQ/Xh58e0lTRMLvEDrcJpR7GwZqAtIeNLcTK7JVE+QineSyHp0kLkO1rttCHyCy0U74kDTj0dRz6raA==} @@ -981,41 +1002,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} From b6921e2720697f09a12adc56b3729b49a45c524f Mon Sep 17 00:00:00 2001 From: NathanosDev Date: Tue, 3 Feb 2026 21:49:33 +0100 Subject: [PATCH 4/8] docs: add comments, for posterity --- packages/pic/src/http2-client.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/pic/src/http2-client.ts b/packages/pic/src/http2-client.ts index 25223f9..0dfc72d 100644 --- a/packages/pic/src/http2-client.ts +++ b/packages/pic/src/http2-client.ts @@ -220,6 +220,7 @@ async function getResBody( return jsonParser(resBody) as ApiResponse; } catch (error) { console.error('Error parsing PocketIC server response body:', error); + // don't break the user's console by logging large response bodies if (resBody.length < 10_240) { console.error('Original body:', resBody); throw new Error(resBody); From b649109ee11c6dedcb3dc29dc5301571466a53e0 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Tue, 17 Feb 2026 14:57:15 +0100 Subject: [PATCH 5/8] fix: use 127.0.0.1 with explicit Host header for macOS CI compatibility --- examples/http/tests/src/http.spec.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/examples/http/tests/src/http.spec.ts b/examples/http/tests/src/http.spec.ts index 200ed8f..2fccab2 100644 --- a/examples/http/tests/src/http.spec.ts +++ b/examples/http/tests/src/http.spec.ts @@ -18,6 +18,7 @@ const WASM_PATH = resolve( describe('HTTP', () => { let pic: PocketIc; let httpGatewayUrl: string; + let httpGatewayHost: string; beforeEach(async () => { pic = await PocketIc.create(process.env.PIC_URL, { @@ -29,7 +30,8 @@ describe('HTTP', () => { await pic.installCode({ canisterId, wasm: WASM_PATH }); const httpGatewayPort = await pic.makeLive(); - httpGatewayUrl = `http://${canisterId}.localhost:${httpGatewayPort}`; + httpGatewayUrl = `http://127.0.0.1:${httpGatewayPort}`; + httpGatewayHost = `${canisterId}.localhost:${httpGatewayPort}`; }); afterEach(async () => { @@ -38,7 +40,12 @@ describe('HTTP', () => { }); it('should return an index.html page', async () => { - const res = await fetch(`${httpGatewayUrl}/index.html`); + // Use 127.0.0.1 with an explicit Host header instead of + // `${canisterId}.localhost` because macOS does not resolve + // *.localhost subdomains on CI runners (mDNSResponder is disabled). + const res = await fetch(`${httpGatewayUrl}/index.html`, { + headers: { Host: httpGatewayHost }, + }); const resBody = await res.text(); expect(resBody).toContain('

Hello, World!

'); }); From 6adb21783510c2380a8c48e60e038f8200abd197 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Tue, 17 Feb 2026 14:57:49 +0100 Subject: [PATCH 6/8] fix: examples/http/tests missing in workspaces array --- bun.lock | 8 ++++++++ package.json | 1 + 2 files changed, 9 insertions(+) diff --git a/bun.lock b/bun.lock index 8928c74..5994225 100644 --- a/bun.lock +++ b/bun.lock @@ -47,6 +47,12 @@ "@dfinity/pic": "workspace:*", }, }, + "examples/http/tests": { + "name": "http-tests", + "devDependencies": { + "@dfinity/pic": "workspace:*", + }, + }, "examples/icp_features/tests": { "name": "icp-features-tests", "devDependencies": { @@ -731,6 +737,8 @@ "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], + "http-tests": ["http-tests@workspace:examples/http/tests"], + "human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], "icp-features-tests": ["icp-features-tests@workspace:examples/icp_features/tests"], diff --git a/package.json b/package.json index 49586ab..e1153d5 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "examples/multicanister/tests", "examples/nns_proxy/tests", "examples/todo/tests", + "examples/http/tests", "packages/pic", "packages/pic-server", "docs" From 53c6ba2711feb212547f31d973243aec6da7713d Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Tue, 17 Feb 2026 15:35:04 +0100 Subject: [PATCH 7/8] fix: skip HTTP tests on macOS CI, switch to Vitest --- examples/http/tests/global-setup.ts | 16 +++++++++++----- examples/http/tests/global-teardown.ts | 3 --- examples/http/tests/jest.config.ts | 14 -------------- examples/http/tests/src/http.spec.ts | 21 ++++++++++----------- examples/http/tests/tsconfig.json | 9 ++------- examples/http/tests/types.d.ts | 12 +++--------- examples/http/tests/vitest.config.ts | 10 ++++++++++ package.json | 2 +- 8 files changed, 37 insertions(+), 50 deletions(-) delete mode 100644 examples/http/tests/global-teardown.ts delete mode 100644 examples/http/tests/jest.config.ts create mode 100644 examples/http/tests/vitest.config.ts diff --git a/examples/http/tests/global-setup.ts b/examples/http/tests/global-setup.ts index 308abf9..7d7aea6 100644 --- a/examples/http/tests/global-setup.ts +++ b/examples/http/tests/global-setup.ts @@ -1,9 +1,15 @@ +import type { TestProject } from 'vitest/node'; import { PocketIcServer } from '@dfinity/pic'; -module.exports = async function (): Promise { - const pic = await PocketIcServer.start(); +let pic: PocketIcServer | undefined; + +export async function setup(ctx: TestProject): Promise { + pic = await PocketIcServer.start(); const url = pic.getUrl(); - process.env.PIC_URL = url; - global.__PIC__ = pic; -}; + ctx.provide('PIC_URL', url); +} + +export async function teardown(): Promise { + await pic?.stop(); +} diff --git a/examples/http/tests/global-teardown.ts b/examples/http/tests/global-teardown.ts deleted file mode 100644 index 487cdff..0000000 --- a/examples/http/tests/global-teardown.ts +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = async function () { - await global.__PIC__.stop(); -}; diff --git a/examples/http/tests/jest.config.ts b/examples/http/tests/jest.config.ts deleted file mode 100644 index fcbdac3..0000000 --- a/examples/http/tests/jest.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { Config } from 'jest'; - -const config: Config = { - watch: false, - transform: { - '^.+\\.(t|j)sx?$': '@swc/jest', - }, - testEnvironment: 'node', - globalSetup: '/global-setup.ts', - globalTeardown: '/global-teardown.ts', - testTimeout: 120_000, -}; - -export default config; diff --git a/examples/http/tests/src/http.spec.ts b/examples/http/tests/src/http.spec.ts index 2fccab2..c525bc0 100644 --- a/examples/http/tests/src/http.spec.ts +++ b/examples/http/tests/src/http.spec.ts @@ -1,5 +1,6 @@ import { resolve } from 'node:path'; import { PocketIc, SubnetStateType } from '@dfinity/pic'; +import { describe, beforeEach, afterEach, it, expect, inject } from 'vitest'; import { _SERVICE } from '../../declarations/http.did'; const WASM_PATH = resolve( @@ -15,13 +16,17 @@ const WASM_PATH = resolve( 'http.wasm.gz', ); -describe('HTTP', () => { +const isCI = !!process.env.CI; +const isMacOS = process.platform === 'darwin'; + +// macOS CI runners have mDNSResponder disabled, so *.localhost subdomains +// don't resolve. Skip until PocketIC supports ?canisterId= query param routing. +describe.skipIf(isCI && isMacOS)('HTTP', () => { let pic: PocketIc; let httpGatewayUrl: string; - let httpGatewayHost: string; beforeEach(async () => { - pic = await PocketIc.create(process.env.PIC_URL, { + pic = await PocketIc.create(inject('PIC_URL'), { nns: { state: { type: SubnetStateType.New } }, application: [{ state: { type: SubnetStateType.New } }], }); @@ -30,8 +35,7 @@ describe('HTTP', () => { await pic.installCode({ canisterId, wasm: WASM_PATH }); const httpGatewayPort = await pic.makeLive(); - httpGatewayUrl = `http://127.0.0.1:${httpGatewayPort}`; - httpGatewayHost = `${canisterId}.localhost:${httpGatewayPort}`; + httpGatewayUrl = `http://${canisterId}.localhost:${httpGatewayPort}`; }); afterEach(async () => { @@ -40,12 +44,7 @@ describe('HTTP', () => { }); it('should return an index.html page', async () => { - // Use 127.0.0.1 with an explicit Host header instead of - // `${canisterId}.localhost` because macOS does not resolve - // *.localhost subdomains on CI runners (mDNSResponder is disabled). - const res = await fetch(`${httpGatewayUrl}/index.html`, { - headers: { Host: httpGatewayHost }, - }); + const res = await fetch(`${httpGatewayUrl}/index.html`); const resBody = await res.text(); expect(resBody).toContain('

Hello, World!

'); }); diff --git a/examples/http/tests/tsconfig.json b/examples/http/tests/tsconfig.json index b09bcf8..29957a5 100644 --- a/examples/http/tests/tsconfig.json +++ b/examples/http/tests/tsconfig.json @@ -2,12 +2,7 @@ "extends": "../../../tsconfig.json", "compilerOptions": { "allowJs": true, - "types": ["jest", "node"] + "types": ["node"] }, - "include": [ - "./src/**/*.ts", - "./global-setup.ts", - "./global-teardown.ts", - "./types.d.ts" - ] + "include": ["./src/**/*.ts", "./global-setup.ts", "types.d.ts"] } diff --git a/examples/http/tests/types.d.ts b/examples/http/tests/types.d.ts index 80781fe..8b8ef52 100644 --- a/examples/http/tests/types.d.ts +++ b/examples/http/tests/types.d.ts @@ -1,11 +1,5 @@ -import { PocketIcServer } from '@dfinity/pic'; - -declare global { - declare var __PIC__: PocketIcServer; - - namespace NodeJS { - interface ProcessEnv { - PIC_URL: string; - } +export declare module 'vitest' { + export interface ProvidedContext { + PIC_URL: string; } } diff --git a/examples/http/tests/vitest.config.ts b/examples/http/tests/vitest.config.ts new file mode 100644 index 0000000..3b6ea28 --- /dev/null +++ b/examples/http/tests/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + root: 'examples/http/tests', + globalSetup: './global-setup.ts', + testTimeout: 120_000, + hookTimeout: 120_000, + }, +}); diff --git a/package.json b/package.json index e1153d5..4e11533 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "build:todo": "dfx generate todo && dfx build todo --check", "test:todo": "jest -c ./examples/todo/tests/jest.config.ts", "build:http": "dfx generate http && dfx build http --check", - "test:http": "jest -c ./examples/http/tests/jest.config.ts", + "test:http": "vitest run -c ./examples/http/tests/vitest.config.ts", "postinstall": "tar -xvf examples/nns_proxy/tests/state/nns_state.tar.xz -C examples/nns_proxy/tests/state" }, "devDependencies": { From 0518e54f2c5bd54dc78d4a2b73249b96a2edce61 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Mon, 23 Feb 2026 10:05:05 +0100 Subject: [PATCH 8/8] fix: use canisterId query param for macOS CI compatibility --- examples/http/tests/src/http.spec.ts | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/examples/http/tests/src/http.spec.ts b/examples/http/tests/src/http.spec.ts index c525bc0..2bd9de2 100644 --- a/examples/http/tests/src/http.spec.ts +++ b/examples/http/tests/src/http.spec.ts @@ -16,14 +16,10 @@ const WASM_PATH = resolve( 'http.wasm.gz', ); -const isCI = !!process.env.CI; -const isMacOS = process.platform === 'darwin'; - -// macOS CI runners have mDNSResponder disabled, so *.localhost subdomains -// don't resolve. Skip until PocketIC supports ?canisterId= query param routing. -describe.skipIf(isCI && isMacOS)('HTTP', () => { +describe('HTTP', () => { let pic: PocketIc; - let httpGatewayUrl: string; + let httpGatewayPort: number; + let canisterId: string; beforeEach(async () => { pic = await PocketIc.create(inject('PIC_URL'), { @@ -31,11 +27,11 @@ describe.skipIf(isCI && isMacOS)('HTTP', () => { application: [{ state: { type: SubnetStateType.New } }], }); - const canisterId = await pic.createCanister(); - await pic.installCode({ canisterId, wasm: WASM_PATH }); + const id = await pic.createCanister(); + await pic.installCode({ canisterId: id, wasm: WASM_PATH }); - const httpGatewayPort = await pic.makeLive(); - httpGatewayUrl = `http://${canisterId}.localhost:${httpGatewayPort}`; + httpGatewayPort = await pic.makeLive(); + canisterId = id.toString(); }); afterEach(async () => { @@ -44,7 +40,12 @@ describe.skipIf(isCI && isMacOS)('HTTP', () => { }); it('should return an index.html page', async () => { - const res = await fetch(`${httpGatewayUrl}/index.html`); + // Use ?canisterId= query param instead of subdomain-based routing + // (e.g. canisterId.localhost) for cross-platform compatibility. + // macOS does not resolve *.localhost subdomains on CI runners. + const res = await fetch( + `http://localhost:${httpGatewayPort}/index.html?canisterId=${canisterId}`, + ); const resBody = await res.text(); expect(resBody).toContain('

Hello, World!

'); });