Skip to content

Commit

Permalink
[repl] initial cli test
Browse files Browse the repository at this point in the history
Co-authored-by: elf Pavlik <[email protected]>
  • Loading branch information
samurex and elf-pavlik committed Oct 9, 2024
1 parent 7b50f2e commit c19e961
Show file tree
Hide file tree
Showing 6 changed files with 335 additions and 116 deletions.
98 changes: 44 additions & 54 deletions packages/repl/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,10 @@
- go back
*/

import * as Command from '@effect/cli/Command';
import * as Prompt from '@effect/cli/Prompt';
import * as NodeContext from '@effect/platform-node/NodeContext';
import * as Runtime from '@effect/platform-node/NodeRuntime';
import { Context, Effect, Layer } from 'effect';
import { Console, Context, Effect, Layer } from 'effect';

import { type Account, accounts, shapeTree, createApp, SolidTestUtils } from '@janeirodigital/css-test-utils';
import { AuthorizationAgent } from '@janeirodigital/interop-authorization-agent';
Expand All @@ -23,12 +22,7 @@ import { init } from '@paralleldrive/cuid2';

const cuid = init({ length: 6 });

console.log('Server starting');
const server = await createApp();
await server.start();
console.log('Server started');

class SessionManager extends Context.Tag('SessionManager')<
export class SessionManager extends Context.Tag('SessionManager')<
SessionManager,
{ readonly getSession: (account: Account) => Effect.Effect<AuthorizationAgent> }
>() {}
Expand Down Expand Up @@ -70,8 +64,8 @@ enum Actions {
const selectActionPrompt = Prompt.select({
message: 'Select action',
choices: [
{ title: 'Create data registration', value: Actions.createDataRegistration },
{ title: 'Create social agent registration', value: Actions.createSocialAgentRegistration }
{ title: 'Create social agent registration', value: Actions.createSocialAgentRegistration },
{ title: 'Create data registration', value: Actions.createDataRegistration }
]
});

Expand All @@ -81,70 +75,66 @@ const getSession = Effect.gen(function* () {
return yield* sessionManager.getSession(accounts[account]);
});

const mainPrompt = Effect.provide(
const createDataRegistration = (session: AuthorizationAgent) =>
Effect.gen(function* () {
const sessionManager = yield* SessionManager;

const action = yield* selectActionPrompt;

switch (action) {
case Actions.createDataRegistration:
yield* createDataRegistration.pipe(Effect.provideService(SessionManager, sessionManager));
break;
case Actions.createSocialAgentRegistration:
yield* createSocialAgentRegistration.pipe(Effect.provideService(SessionManager, sessionManager));
break;
}
}),
SessionManagerLive
);
const registryId = yield* createSelectDataRegistryPrompt(session.registrySet.hasDataRegistry.map(({ iri }) => iri));

const createDataRegistration = Effect.gen(function* () {
const sessionManager = yield* SessionManager;
const registry = session.registrySet.hasDataRegistry.find(({ iri }) => iri === registryId)!;

const session = yield* getSession.pipe(Effect.provideService(SessionManager, sessionManager));
const registrations = yield* Effect.promise(async () => asyncIterableToArray(registry.registrations));

const registryId = yield* createSelectDataRegistryPrompt(session.registrySet.hasDataRegistry.map(({ iri }) => iri));
const existingShapeTrees = registrations.map((registration) => registration.shapeTree.iri);

const registry = session.registrySet.hasDataRegistry.find(({ iri }) => iri === registryId)!;
existingShapeTrees.forEach((iri) => console.log(iri));

const registrations = yield* Effect.promise(async () => asyncIterableToArray(registry.registrations));
const remainingShapeTrees = [...new Set(Object.values(shapeTree)).difference(new Set(existingShapeTrees))];

const existingShapeTrees = registrations.map((registration) => registration.shapeTree.iri);
const shapeTreeId = yield* createSelectShapeTreePrompt(remainingShapeTrees);

existingShapeTrees.forEach((iri) => console.log(iri));

const remainingShapeTrees = [...new Set(Object.values(shapeTree)).difference(new Set(existingShapeTrees))];
yield* Effect.promise(async () => registry.createRegistration(shapeTreeId));
});

const shapeTreeId = yield* createSelectShapeTreePrompt(remainingShapeTrees);
const createSocialAgentRegistration = (session: AuthorizationAgent) =>
Effect.gen(function* () {
const webId = yield* Prompt.text({ message: 'Enter social agent webId' });
const label = yield* Prompt.text({ message: 'Enter social agent label' });
const note = yield* Prompt.text({ message: 'Enter social agent note (optional)' });

yield* Effect.promise(async () => registry.createRegistration(shapeTreeId));
});
yield* Effect.promise(async () =>
session.registrySet.hasAgentRegistry.addSocialAgentRegistration(webId, label, note)
);
});

const createSocialAgentRegistration = Effect.gen(function* () {
export const mainPrompt = Effect.gen(function* () {
const sessionManager = yield* SessionManager;

const account = yield* accountPrompt;

const session = yield* sessionManager.getSession(accounts[account]);
const session = yield* getSession.pipe(Effect.provideService(SessionManager, sessionManager));

const webId = yield* Prompt.text({ message: 'Enter social agent name' });
const label = yield* Prompt.text({ message: 'Enter social agent label' });
const note = yield* Prompt.text({ message: 'Enter social agent note (optional)' });
const action = yield* selectActionPrompt;

yield* Effect.promise(async () =>
session.registrySet.hasAgentRegistry.addSocialAgentRegistration(webId, label, note)
);
switch (action) {
case Actions.createDataRegistration:
yield* createDataRegistration(session);
break;
case Actions.createSocialAgentRegistration:
yield* createSocialAgentRegistration(session);
break;
}
});

const command = Command.make('sai', {}, () => mainPrompt);
const program = Effect.gen(function* () {
yield* Console.log('Server starting');
const server = yield* Effect.promise(() => createApp());
yield* Effect.promise(() => server.start());
yield* Console.log('Server started');
yield* Effect.addFinalizer(() => Effect.promise(() => server.stop()));

yield* Effect.provide(mainPrompt, SessionManagerLive);

const cli = Command.run(command, {
name: 'Prompt Examples',
version: '0.0.1'
return 1;
});

Effect.suspend(() => cli(process.argv)).pipe(Effect.provide(NodeContext.layer), Runtime.runMain);
Effect.suspend(() => Effect.scoped(program)).pipe(Effect.provide(NodeContext.layer), Runtime.runMain);

async function buildSession(account: Account): Promise<AuthorizationAgent> {
const stu = new SolidTestUtils(account);
Expand Down
10 changes: 6 additions & 4 deletions packages/repl/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,17 @@
},
"scripts": {
"repl": "tsx ./repl.ts",
"cli": "NODE_OPTIONS='--no-deprecation' tsx ./cli.ts"
"cli": "NODE_OPTIONS='--no-deprecation' tsx ./cli.ts",
"test": "NODE_ENV=vitest vitest run --coverage"
},
"dependencies": {
"@effect/cli": "^0.47.1",
"@effect/platform-node": "^0.63.1",
"@janeirodigital/interop-utils": "^1.0.0-rc.24",
"@effect/cli": "^0.43.2",
"@effect/platform-node": "^0.59.0",
"effect": "^3.7.2"
"effect": "^3.9.1"
},
"devDependencies": {
"@effect/platform": "^0.68.1",
"@janeirodigital/css-test-utils": "^1.0.0-rc.24",
"@janeirodigital/interop-authorization-agent": "workspace:^1.0.0-rc.24",
"@janeirodigital/interop-data-model": "workspace:^1.0.0-rc.24",
Expand Down
58 changes: 58 additions & 0 deletions packages/repl/test/cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type * as CliApp from '@effect/cli/CliApp';
import { NodeFileSystem, NodePath } from '@effect/platform-node';
import * as Console from 'effect/Console';
import * as Effect from 'effect/Effect';
import * as Fiber from 'effect/Fiber';
import * as Layer from 'effect/Layer';
import { describe, expect, it, vi } from 'vitest';
import * as MockConsole from './services/MockConsole';
import * as MockTerminal from './services/MockTerminal';
import { mainPrompt, SessionManager } from '../cli';
import { AuthorizationAgent } from '@janeirodigital/interop-authorization-agent';

const addSocialAgentRegistration = vi.fn();

const SessionManagerMock = Layer.succeed(
SessionManager,
SessionManager.of({
getSession: () =>
Effect.succeed({
registrySet: {
hasAgentRegistry: {
addSocialAgentRegistration
}
}
} as unknown as AuthorizationAgent)
})
);

const MainLive = Effect.gen(function* () {
const console = yield* MockConsole.make;
return Layer.mergeAll(Console.setConsole(console), NodeFileSystem.layer, MockTerminal.layer, NodePath.layer);
}).pipe(Layer.unwrapEffect);

const runEffect = <E, A>(self: Effect.Effect<A, E, CliApp.CliApp.Environment>): Promise<A> =>
Effect.provide(self, MainLive).pipe(Effect.runPromise);

describe('cli', () => {
describe('mainPrompt', () => {
it('should generate data registration', () =>
Effect.gen(function* () {
const fiber = yield* Effect.fork(Effect.provide(mainPrompt, SessionManagerMock));
// select first account
yield* MockTerminal.inputKey('enter');
// select first action
yield* MockTerminal.inputKey('enter');
yield* MockTerminal.inputText('https://example.com/webid');
yield* MockTerminal.inputKey('enter');
yield* MockTerminal.inputText('alice');
yield* MockTerminal.inputKey('enter');
// note is empty
yield* MockTerminal.inputKey('enter');

yield* Fiber.join(fiber);

expect(addSocialAgentRegistration).toHaveBeenCalledWith('https://example.com/webid', 'alice', '');
}).pipe(runEffect));
});
});
65 changes: 65 additions & 0 deletions packages/repl/test/services/MockConsole.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/* eslint-disable */
// @ts-nocheck
import * as Array from 'effect/Array';
import * as Console from 'effect/Console';
import * as Context from 'effect/Context';
import * as Effect from 'effect/Effect';
import * as Ref from 'effect/Ref';

export interface MockConsole extends Console.Console {
readonly getLines: (
params?: Partial<{
readonly stripAnsi: boolean;
}>
) => Effect.Effect<ReadonlyArray<string>>;
}

export const MockConsole = Context.GenericTag<Console.Console, MockConsole>('effect/Console');
const pattern = new RegExp(
[
'[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)',
'(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))'
].join('|'),
'g'
);

const stripAnsi = (str: string) => str.replace(pattern, '');

export const make = Effect.gen(function* (_) {
const lines = yield* _(Ref.make(Array.empty<string>()));

const getLines: MockConsole['getLines'] = (params = {}) =>
Ref.get(lines).pipe(Effect.map((lines) => (params.stripAnsi || false ? Array.map(lines, stripAnsi) : lines)));

const log: MockConsole['log'] = (...args) => Ref.update(lines, Array.appendAll(args));

return MockConsole.of({
[Console.TypeId]: Console.TypeId,
getLines,
log,
unsafe: globalThis.console,
assert: () => Effect.void,
clear: Effect.void,
count: () => Effect.void,
countReset: () => Effect.void,
debug: () => Effect.void,
dir: () => Effect.void,
dirxml: () => Effect.void,
error: () => Effect.void,
group: () => Effect.void,
groupEnd: Effect.void,
info: () => Effect.void,
table: () => Effect.void,
time: () => Effect.void,
timeEnd: () => Effect.void,
timeLog: () => Effect.void,
trace: () => Effect.void,
warn: () => Effect.void
});
});

export const getLines = (
params?: Partial<{
readonly stripAnsi?: boolean;
}>
): Effect.Effect<ReadonlyArray<string>> => Effect.consoleWith((console) => (console as MockConsole).getLines(params));
101 changes: 101 additions & 0 deletions packages/repl/test/services/MockTerminal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/* eslint-disable */
// @ts-nocheck
import * as Terminal from '@effect/platform/Terminal';
import * as Array from 'effect/Array';
import * as Console from 'effect/Console';
import * as Context from 'effect/Context';
import * as Effect from 'effect/Effect';
import * as Layer from 'effect/Layer';
import * as Option from 'effect/Option';
import * as Queue from 'effect/Queue';

// =============================================================================
// Models
// =============================================================================

export interface MockTerminal extends Terminal.Terminal {
readonly inputText: (text: string) => Effect.Effect<void>;
readonly inputKey: (key: string, modifiers?: Partial<MockTerminal.Modifiers>) => Effect.Effect<void>;
}

export declare namespace MockTerminal {
export interface Modifiers {
readonly ctrl: boolean;
readonly meta: boolean;
readonly shift: boolean;
}
}

// =============================================================================
// Context
// =============================================================================

export const MockTerminal = Context.GenericTag<Terminal.Terminal, MockTerminal>('@effect/platform/Terminal');

// =============================================================================
// Constructors
// =============================================================================

export const make = Effect.gen(function* (_) {
const queue = yield* _(Effect.acquireRelease(Queue.unbounded<Terminal.UserInput>(), Queue.shutdown));

const inputText: MockTerminal['inputText'] = (text: string) => {
const inputs = Array.map(text.split(''), (key) => toUserInput(key));
return Queue.offerAll(queue, inputs).pipe(Effect.asVoid);
};

const inputKey: MockTerminal['inputKey'] = (key: string, modifiers?: Partial<MockTerminal.Modifiers>) => {
const input = toUserInput(key, modifiers);
return Queue.offer(queue, input).pipe(Effect.asVoid);
};

const display: MockTerminal['display'] = (input) => Console.log(input);

const readInput: MockTerminal['readInput'] = Queue.take(queue).pipe(
Effect.filterOrFail(
(input) => !shouldQuit(input),
() => new Terminal.QuitException()
),
Effect.timeoutFail({
duration: '2 seconds',
onTimeout: () => new Terminal.QuitException()
})
);

return MockTerminal.of({
columns: Effect.succeed(80),
display,
readInput,
readLine: Effect.succeed(''),
inputKey,
inputText
});
});

// =============================================================================
// Layer
// =============================================================================

export const layer = Layer.scoped(MockTerminal, make);

// =============================================================================
// Accessors
// =============================================================================

export const { columns, readInput, readLine } = Effect.serviceConstants(MockTerminal);
export const { inputKey, inputText } = Effect.serviceFunctions(MockTerminal);

// =============================================================================
// Utilities
// =============================================================================

const shouldQuit = (input: Terminal.UserInput): boolean =>
input.key.ctrl && (input.key.name === 'c' || input.key.name === 'd');

const toUserInput = (key: string, modifiers: Partial<MockTerminal.Modifiers> = {}): Terminal.UserInput => {
const { ctrl = false, meta = false, shift = false } = modifiers;
return {
input: Option.some(key),
key: { name: key, ctrl, meta, shift }
};
};
Loading

0 comments on commit c19e961

Please sign in to comment.