Skip to content

Commit

Permalink
feat: add store for history (#192)
Browse files Browse the repository at this point in the history
### What does this PR do?

Adds a notification system / store for history so the frontend can be
notified and correctly retrieve the file history.

It "refreshes" based on file change, deletion or creation.

### Screenshot / video of UI

<!-- If this PR is changing UI, please include
screenshots or screencasts showing the difference -->

N/A

### What issues does this PR fix or reference?

<!-- Include any related issues from Podman Desktop
repository (or from another issue tracker). -->

Closes #188

Part of #150

### How to test this PR?

<!-- Please explain steps to reproduce -->

- [x] Covered by tests

Signed-off-by: Charlie Drage <[email protected]>
  • Loading branch information
cdrage authored Mar 20, 2024
1 parent ea2b302 commit c362420
Show file tree
Hide file tree
Showing 9 changed files with 385 additions and 3 deletions.
10 changes: 10 additions & 0 deletions packages/backend/src/extension.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import type * as podmanDesktopApi from '@podman-desktop/api';
import { activate, deactivate } from './extension';
import * as fs from 'node:fs';
import os from 'node:os';

/// mock console.log
const originalConsoleLog = console.log;
Expand All @@ -48,6 +49,13 @@ vi.mock('@podman-desktop/api', async () => {
onDidChangeViewState: vi.fn(),
}),
},
fs: {
createFileSystemWatcher: () => ({
onDidCreate: vi.fn(),
onDidDelete: vi.fn(),
onDidChange: vi.fn(),
}),
},
};
});

Expand All @@ -61,10 +69,12 @@ afterEach(() => {
});

test('check activate', async () => {
const tmpDir = os.tmpdir();
const fakeContext = {
subscriptions: {
push: vi.fn(),
},
storagePath: tmpDir,
} as unknown as podmanDesktopApi.ExtensionContext;
vi.spyOn(fs.promises, 'readFile').mockImplementation(() => {
return Promise.resolve('<html></html>');
Expand Down
6 changes: 6 additions & 0 deletions packages/backend/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import fs from 'node:fs';
import { bootcBuildOptionSelection } from './quickpicks';
import { RpcExtension } from '/@shared/src/messages/MessageProxy';
import { BootcApiImpl } from './api-impl';
import { HistoryNotifier } from './history/historyNotifier';

export async function activate(extensionContext: ExtensionContext): Promise<void> {
console.log('starting bootc extension');
Expand Down Expand Up @@ -98,6 +99,11 @@ export async function activate(extensionContext: ExtensionContext): Promise<void
const rpcExtension = new RpcExtension(panel.webview);
const bootcApi = new BootcApiImpl(extensionContext);
rpcExtension.registerInstance<BootcApiImpl>(BootcApiImpl, bootcApi);

// Create the historyNotifier and push to subscriptions
// so the frontend can be notified when the history changes and so we can update the UI / call listHistoryInfo
const historyNotifier = new HistoryNotifier(panel.webview, extensionContext.storagePath);
extensionContext.subscriptions.push(historyNotifier);
}

export async function deactivate(): Promise<void> {
Expand Down
6 changes: 3 additions & 3 deletions packages/backend/src/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises';
import * as path from 'node:path';
import type { BootcBuildInfo } from '/@shared/src/models/bootc';

const filename = 'history.json';
export const BOOTC_HISTORY_FILENAME = 'history.json';

export class History {
private infos: BootcBuildInfo[] = [];
Expand All @@ -38,7 +38,7 @@ export class History {
return;
}

const filePath = path.resolve(this.storagePath, filename);
const filePath = path.resolve(this.storagePath, BOOTC_HISTORY_FILENAME);
if (!existsSync(filePath)) {
return;
}
Expand Down Expand Up @@ -86,7 +86,7 @@ export class History {
protected async saveFile(): Promise<void> {
try {
await mkdir(this.storagePath, { recursive: true });
const filePath = path.resolve(this.storagePath, filename);
const filePath = path.resolve(this.storagePath, BOOTC_HISTORY_FILENAME);
await writeFile(filePath, JSON.stringify(this.infos, undefined, 2));
} catch (err) {
console.error('Error saving file:', err);
Expand Down
106 changes: 106 additions & 0 deletions packages/backend/src/history/historyNotifier.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**********************************************************************
* Copyright (C) 2024 Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/

import { beforeEach, describe, expect, test, vi } from 'vitest';
import os from 'os';
import type { Webview, FileSystemWatcher } from '@podman-desktop/api';
import { fs } from '@podman-desktop/api';
import { HistoryNotifier } from './historyNotifier';

vi.mock('@podman-desktop/api', () => {
return {
fs: {
createFileSystemWatcher: () => ({
onDidCreate: vi.fn(),
onDidDelete: vi.fn(),
onDidChange: vi.fn(),
}),
},
window: {
showErrorMessage: vi.fn(),
},
};
});

vi.mock('node:fs', () => {
return {
existsSync: vi.fn(),
promises: {
readFile: vi.fn(),
},
};
});

let historyMock: HistoryNotifier;

beforeEach(() => {
vi.resetAllMocks();
});

test('Expect postMessage to be called when doing .notify', async () => {
const tmpDir = os.tmpdir();
console.log(tmpDir);
const postMessageMock = vi.fn().mockResolvedValue(undefined);
historyMock = new HistoryNotifier({ postMessage: postMessageMock } as unknown as Webview, '/tmp/foobar');
await historyMock.notify();
expect(postMessageMock).toHaveBeenCalledTimes(1);
});

describe('Tests involving a file system change', () => {
let onDidCreateListener: () => void;
let onDidDeleteListener: () => void;
let onDidChangeListener: () => void;

beforeEach(() => {
vi.spyOn(fs, 'createFileSystemWatcher').mockReturnValue({
onDidCreate: vi.fn().mockImplementation(listener => (onDidCreateListener = listener)),
onDidDelete: vi.fn().mockImplementation(listener => (onDidDeleteListener = listener)),
onDidChange: vi.fn().mockImplementation(listener => (onDidChangeListener = listener)),
} as unknown as FileSystemWatcher);
});

test('Expect notify to be called when onDidChange is triggered', async () => {
const postMessageMock = vi.fn().mockResolvedValue(undefined);
historyMock = new HistoryNotifier({ postMessage: postMessageMock } as unknown as Webview, '/tmp/foobar');
onDidChangeListener();
expect(postMessageMock).toHaveBeenCalledTimes(1);
});

test('Expect notify to be called when onDidCreate is triggered', async () => {
const postMessageMock = vi.fn().mockResolvedValue(undefined);
historyMock = new HistoryNotifier({ postMessage: postMessageMock } as unknown as Webview, '/tmp/foobar');
onDidCreateListener();
expect(postMessageMock).toHaveBeenCalledTimes(1);
});

test('Expect notify to be called when onDidDelete is triggered', async () => {
const postMessageMock = vi.fn().mockResolvedValue(undefined);
historyMock = new HistoryNotifier({ postMessage: postMessageMock } as unknown as Webview, '/tmp/foobar');
onDidDeleteListener();
expect(postMessageMock).toHaveBeenCalledTimes(1);
});

test('Expect notify to be called 3 times if file system watcher events are triggered 3 times', async () => {
const postMessageMock = vi.fn().mockResolvedValue(undefined);
historyMock = new HistoryNotifier({ postMessage: postMessageMock } as unknown as Webview, '/tmp/foobar');
onDidChangeListener();
onDidCreateListener();
onDidDeleteListener();
expect(postMessageMock).toHaveBeenCalledTimes(3);
});
});
47 changes: 47 additions & 0 deletions packages/backend/src/history/historyNotifier.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**********************************************************************
* Copyright (C) 2024 Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/

import { type Disposable, type Webview } from '@podman-desktop/api';
import * as podmanDesktopApi from '@podman-desktop/api';
import path from 'node:path';
import { Messages } from '/@shared/src/messages/Messages';
import { BOOTC_HISTORY_FILENAME } from '../history';

export class HistoryNotifier implements Disposable {
#watcher: podmanDesktopApi.FileSystemWatcher;

constructor(
private webview: Webview,
private readonly storagePath: string,
) {
this.#watcher = podmanDesktopApi.fs.createFileSystemWatcher(path.join(this.storagePath, BOOTC_HISTORY_FILENAME));
this.#watcher.onDidChange(this.notify.bind(this));
this.#watcher.onDidCreate(this.notify.bind(this));
this.#watcher.onDidDelete(this.notify.bind(this));
}

async notify(): Promise<void> {
await this.webview.postMessage({
id: Messages.MSG_HISTORY_UPDATE,
});
}

public async dispose(): Promise<void> {
this.#watcher.dispose();
}
}
11 changes: 11 additions & 0 deletions packages/frontend/src/stores/historyInfo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { Readable } from 'svelte/store';
import { Messages } from '/@shared/src/messages/Messages';
import { bootcClient } from '/@/api/client';
import type { BootcBuildInfo } from '/@shared/src/models/bootc';
import { RPCReadable } from '/@/stores/rpcReadable';

export const historyInfo: Readable<BootcBuildInfo[]> = RPCReadable<BootcBuildInfo[]>(
[],
[Messages.MSG_HISTORY_UPDATE],
bootcClient.listHistoryInfo,
);
88 changes: 88 additions & 0 deletions packages/frontend/src/stores/rpcReadable.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**********************************************************************
* Copyright (C) 2024 Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/

import { beforeEach, expect, test, vi } from 'vitest';
import { RpcBrowser } from '/@shared/src/messages/MessageProxy';
import { RPCReadable } from './rpcReadable';
import { bootcClient, rpcBrowser } from '/@/api/client';
vi.mock('/@/api/client', async () => {
const window = {
addEventListener: (_: string, _f: (message: unknown) => void) => {},
} as unknown as Window;

const api = {
postMessage: (message: unknown) => {
if (message && typeof message === 'object' && 'channel' in message) {
const f = rpcBrowser.subscribers.get(message.channel as string);
f?.('');
}
},
} as unknown as PodmanDesktopApi;

const rpcBrowser = new RpcBrowser(window, api);

return {
rpcBrowser: rpcBrowser,
bootcClient: {
listHistoryInfo: vi.fn(),
},
};
});

beforeEach(() => {
vi.clearAllMocks();
});

test('check updater is called once at subscription', async () => {
const spyOnListHistoryInfo = vi.spyOn(bootcClient, 'listHistoryInfo');
const rpcWritable = RPCReadable<string[]>([], [], () => {
bootcClient.listHistoryInfo();
return Promise.resolve(['']);
});
rpcWritable.subscribe(_ => {});
expect(spyOnListHistoryInfo).toHaveBeenCalledTimes(1);
});

test('check updater is called twice if there is one event fired', async () => {
const spyOnListHistoryInfo = vi.spyOn(bootcClient, 'listHistoryInfo');
const rpcWritable = RPCReadable<string[]>([], ['event'], () => {
bootcClient.listHistoryInfo();
return Promise.resolve(['']);
});
rpcWritable.subscribe(_ => {});
rpcBrowser.invoke('event');
// wait for the timeout in the debouncer
await new Promise(resolve => setTimeout(resolve, 600));
expect(spyOnListHistoryInfo).toHaveBeenCalledTimes(2);
});

test('check updater is called only twice because of the debouncer if there is more than one event in a row', async () => {
const spyOnListHistoryInfo = vi.spyOn(bootcClient, 'listHistoryInfo');
const rpcWritable = RPCReadable<string[]>([], ['event2'], () => {
bootcClient.listHistoryInfo();
return Promise.resolve(['']);
});
rpcWritable.subscribe(_ => {});
rpcBrowser.invoke('event2');
rpcBrowser.invoke('event2');
rpcBrowser.invoke('event2');
rpcBrowser.invoke('event2');
// wait for the timeout in the debouncer
await new Promise(resolve => setTimeout(resolve, 600));
expect(spyOnListHistoryInfo).toHaveBeenCalledTimes(2);
});
Loading

0 comments on commit c362420

Please sign in to comment.