diff --git a/packages/pic/src/management-canister.ts b/packages/pic/src/management-canister.ts index 034fa3b..5b53ada 100644 --- a/packages/pic/src/management-canister.ts +++ b/packages/pic/src/management-canister.ts @@ -156,3 +156,52 @@ export function encodeUpdateCanisterSettingsRequest( ): Uint8Array { return new Uint8Array(IDL.encode([UpdateCanisterSettingsRequest], [arg])); } + +const CanisterLogRecord = IDL.Record({ + idx: IDL.Nat64, + timestamp_nanos: IDL.Nat64, + content: IDL.Vec(IDL.Nat8), +}); + +export interface CanisterLogRecord { + idx: bigint; + timestamp_nanos: bigint; + content: Uint8Array; +} + +const FetchCanisterLogsRequest = IDL.Record({ + canister_id: IDL.Principal, +}); + +export interface FetchCanisterLogsRequest { + canister_id: Principal; +} + +export function encodeFetchCanisterLogsRequest( + arg: FetchCanisterLogsRequest, +): Uint8Array { + return new Uint8Array(IDL.encode([FetchCanisterLogsRequest], [arg])); +} + +const FetchCanisterLogsResponse = IDL.Record({ + canister_log_records: IDL.Vec(CanisterLogRecord), +}); + +export interface FetchCanisterLogsResponse { + canister_log_records: CanisterLogRecord[]; +} + +export function decodeFetchCanisterLogsResponse( + arg: Uint8Array, +): FetchCanisterLogsResponse { + const payload = decodeCandid( + [FetchCanisterLogsResponse], + arg, + ); + + if (isNil(payload)) { + throw new Error('Failed to decode FetchCanisterLogsResponse'); + } + + return payload; +} diff --git a/packages/pic/src/pocket-ic-types.ts b/packages/pic/src/pocket-ic-types.ts index 87787a1..11c1402 100644 --- a/packages/pic/src/pocket-ic-types.ts +++ b/packages/pic/src/pocket-ic-types.ts @@ -920,3 +920,46 @@ export interface HttpsOutcallRejectResponse { } //#endregion HTTPS Outcalls + +//#region FetchCanisterLogs + +/** + * Options for fetching canister logs. + * + * @category Types + * @see [Principal](https://js.icp.build/core/latest/libs/principal/api/classes/principal/) + */ +export interface FetchCanisterLogsOptions { + /** + * The Principal of the canister to fetch logs for. + */ + canisterId: Principal; + + /** + * The Principal to send the request as. + * Defaults to the anonymous principal. + */ + sender?: Principal; +} + +/** + * A canister log record. + */ +export interface CanisterLogRecord { + /** + * The index of the log record. + */ + idx: bigint; + + /** + * The timestamp of the log record in nanoseconds since epoch. + */ + timestampNanos: bigint; + + /** + * The content of the log record. + */ + content: Uint8Array; +} + +//#endregion FetchCanisterLogs diff --git a/packages/pic/src/pocket-ic.ts b/packages/pic/src/pocket-ic.ts index 8b1a94c..d57c496 100644 --- a/packages/pic/src/pocket-ic.ts +++ b/packages/pic/src/pocket-ic.ts @@ -20,6 +20,8 @@ import { UpdateCallOptions, PendingHttpsOutcall, MockPendingHttpsOutcallOptions, + FetchCanisterLogsOptions, + CanisterLogRecord as CanisterLogRecordPublic, } from './pocket-ic-types'; import { MANAGEMENT_CANISTER_ID, @@ -28,6 +30,8 @@ import { encodeInstallCodeRequest, encodeStartCanisterRequest, encodeUpdateCanisterSettingsRequest, + encodeFetchCanisterLogsRequest, + decodeFetchCanisterLogsResponse, } from './management-canister'; import { createDeferredActorClass, @@ -694,6 +698,37 @@ export class PocketIc { return res.body; } + /** + * Fetches the logs for the given canister. + * + * @param options Options for fetching canister logs, see {@link FetchCanisterLogsOptions}. + * @returns An array of {@link CanisterLogRecord} entries. + */ + public async fetchCanisterLogs({ + canisterId, + sender = Principal.anonymous(), + }: FetchCanisterLogsOptions): Promise { + const payload = encodeFetchCanisterLogsRequest({ + canister_id: canisterId, + }); + + const res = await this.client.queryCall({ + canisterId: MANAGEMENT_CANISTER_ID, + sender, + method: 'fetch_canister_logs', + payload, + effectivePrincipal: { canisterId }, + }); + + const decoded = decodeFetchCanisterLogsResponse(res.body); + + return decoded.canister_log_records.map(record => ({ + idx: record.idx, + timestampNanos: record.timestamp_nanos, + content: new Uint8Array(record.content), + })); + } + /** * Makes an update call to the given canister. * diff --git a/packages/pic/tests/src/fetchCanisterLogs.spec.ts b/packages/pic/tests/src/fetchCanisterLogs.spec.ts new file mode 100644 index 0000000..bfd3ce6 --- /dev/null +++ b/packages/pic/tests/src/fetchCanisterLogs.spec.ts @@ -0,0 +1,42 @@ +import { CONTROLLER, TestFixture } from './util'; + +describe('fetchCanisterLogs', () => { + let fixture: TestFixture; + + beforeEach(async () => { + fixture = await TestFixture.create(); + }); + + afterEach(async () => { + await fixture.tearDown(); + }); + + it('should fetch canister logs', async () => { + const message = 'Hello from canister'; + + await fixture.actor.print_log(message); + + const logs = await fixture.pic.fetchCanisterLogs({ + canisterId: fixture.canisterId, + sender: CONTROLLER.getPrincipal(), + }); + + expect(logs.length).toBeGreaterThanOrEqual(1); + + const lastLog = logs[logs.length - 1]; + const logContent = new TextDecoder().decode(lastLog.content); + + expect(logContent).toBe(message); + expect(lastLog.idx).toBeDefined(); + expect(lastLog.timestampNanos).toBeDefined(); + }); + + it('should return empty logs for a canister with no logs', async () => { + const logs = await fixture.pic.fetchCanisterLogs({ + canisterId: fixture.canisterId, + sender: CONTROLLER.getPrincipal(), + }); + + expect(logs).toEqual([]); + }); +}); diff --git a/packages/pic/tests/test-canister/declarations/test_canister.did.d.ts b/packages/pic/tests/test-canister/declarations/test_canister.did.d.ts index 2097688..4926e53 100644 --- a/packages/pic/tests/test-canister/declarations/test_canister.did.d.ts +++ b/packages/pic/tests/test-canister/declarations/test_canister.did.d.ts @@ -3,6 +3,9 @@ import type { ActorMethod } from '@dfinity/agent'; import type { IDL } from '@dfinity/candid'; export type Time = bigint; -export interface _SERVICE { 'get_time' : ActorMethod<[], Time> } +export interface _SERVICE { + 'get_time' : ActorMethod<[], Time>, + 'print_log' : ActorMethod<[string], undefined>, +} export declare const idlFactory: IDL.InterfaceFactory; export declare const init: (args: { IDL: typeof IDL }) => IDL.Type[]; diff --git a/packages/pic/tests/test-canister/declarations/test_canister.did.js b/packages/pic/tests/test-canister/declarations/test_canister.did.js index 11d120e..6d5527d 100644 --- a/packages/pic/tests/test-canister/declarations/test_canister.did.js +++ b/packages/pic/tests/test-canister/declarations/test_canister.did.js @@ -1,5 +1,8 @@ export const idlFactory = ({ IDL }) => { const Time = IDL.Int; - return IDL.Service({ 'get_time' : IDL.Func([], [Time], ['query']) }); + return IDL.Service({ + 'get_time' : IDL.Func([], [Time], ['query']), + 'print_log' : IDL.Func([IDL.Text], [], []), + }); }; export const init = ({ IDL }) => { return []; }; diff --git a/packages/pic/tests/test-canister/main.mo b/packages/pic/tests/test-canister/main.mo index d081914..02fdf49 100644 --- a/packages/pic/tests/test-canister/main.mo +++ b/packages/pic/tests/test-canister/main.mo @@ -1,7 +1,12 @@ import Time "mo:base/Time"; +import Debug "mo:base/Debug"; persistent actor TestCanister { public query func get_time() : async Time.Time { return Time.now(); }; + + public func print_log(message : Text) : async () { + Debug.print(message); + }; }; diff --git a/packages/pic/tests/test-canister/test_canister.wasm.gz b/packages/pic/tests/test-canister/test_canister.wasm.gz index 0d397bd..d76b627 100644 Binary files a/packages/pic/tests/test-canister/test_canister.wasm.gz and b/packages/pic/tests/test-canister/test_canister.wasm.gz differ