Skip to content

Commit

Permalink
Added test implementation and helpers. Added initial workflow. Lock
Browse files Browse the repository at this point in the history
  • Loading branch information
f1yn committed Jan 20, 2024
1 parent bc962af commit b86bf85
Show file tree
Hide file tree
Showing 8 changed files with 938 additions and 0 deletions.
23 changes: 23 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: test

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
test:
name: Run tests
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3

- name: Setup Deno
uses: ./
with:
deno-version: 1.39.4

- name: Test
run: deno test --allow-env --allow-read --trace-ops
208 changes: 208 additions & 0 deletions deno.lock

Large diffs are not rendered by default.

176 changes: 176 additions & 0 deletions tests/basic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { assertArrayIncludes, assertEquals } from './internal/deps.ts';
import { stubFetch, stubReadTextFile } from './internal/stubs.ts';
import { createFixedTimepoint, moveTimeToPosition } from './internal/time.ts';
import createSlackActionsApi from './slack.ts';

Deno.test('Simple schedule test', async () => {
const helpers = await basicTestSetup(`
[first-status]
start = 16:00:00
end = 17:00:00
icon = ":test:"
message = [
"This is my test status message",
"This is another possible status message"
]
days = ["Mon", "Tue", "Wed", "Thu", "Fri"]
doNotDisturb = true
[second-status]
start = 18:00:00
end = 20:00:00
icon = ":test2:"
message = [
"EEEEEE",
"AAAAAAH"
]
days = ["Tue", "Wed", "Thu", "Fri"]
`);

const { time, runtime } = helpers;

try {
// set a specific time that we know
moveTimeToPosition(time, '16:03');

// Queue the requests we expect to intercept - note that missing requests will fail the test
const userInfoRequest1 = helpers.slackApi.getProfileRequest();
const dndInfoRequest1 = helpers.slackApi.getDndInfoRequest();
const userSetRequest1 = helpers.slackApi.updateProfileRequest();
const dndSetRequest1 = helpers.slackApi.setDndSnoozeRequest();

// First cycle iteration
await Promise.all([
runtime.executeTask(),
userInfoRequest1,
dndInfoRequest1,
userSetRequest1,
dndSetRequest1,
]);

// Verify that the expected values were set
helpers.slackApi.assert((state) => {
assertEquals(state.statusEmoji, ':test:');
assertEquals(state.statusExpiration, 820533600);
assertEquals(state.dndDurationMinutes, 56);
assertArrayIncludes([
'This is my test status message',
'This is another possible status message',
], [state.statusText]);
});

// move time to empty allocation (status should unset)
moveTimeToPosition(helpers.time, '17:30');

// Again, queue the requests we expect to intercept
const userInfoRequest2 = helpers.slackApi.getProfileRequest();
const dndInfoRequest2 = helpers.slackApi.getDndInfoRequest();
const userSetRequest2 = helpers.slackApi.updateProfileRequest();
const dndEndRequest2 = helpers.slackApi.endDndSnoozeRequest();

// Second iteration (should empty status, and end DnD)
await Promise.all([
runtime.executeTask(),
userInfoRequest2,
dndInfoRequest2,
userSetRequest2,
dndEndRequest2,
]);

// Verify that mocked Slack api has unset state
helpers.slackApi.assert((state) => {
assertEquals(state.statusEmoji, null);
assertEquals(state.statusExpiration, null);
assertEquals(state.dndDurationMinutes, 0);
assertEquals(state.statusText, '');
});

// move time to next allocation (no overlap because it should be monday)
moveTimeToPosition(helpers.time, '18:10');

// Queue the requests we expect to intercept
const userInfoRequest3 = helpers.slackApi.getProfileRequest();
const dndInfoRequest3 = helpers.slackApi.getDndInfoRequest();

// Perform work cycle
await Promise.all([
runtime.executeTask(),
userInfoRequest3,
dndInfoRequest3,
]);

// Verify that mocked Slack api has unset state
helpers.slackApi.assert((state) => {
assertEquals(state.statusEmoji, null);
assertEquals(state.statusExpiration, null);
assertEquals(state.dndDurationMinutes, 0);
assertEquals(state.statusText, '');
});

// move time to 19:00 hours on the following day (should be Tuesday)
moveTimeToPosition(helpers.time, '19:00', 1);

const userInfoRequest4 = helpers.slackApi.getProfileRequest();
const dndInfoRequest4 = helpers.slackApi.getDndInfoRequest();
const userSetRequest4 = helpers.slackApi.updateProfileRequest();

await Promise.all([
runtime.executeTask(),
userInfoRequest4,
dndInfoRequest4,
userSetRequest4,
]);

helpers.slackApi.assert((state) => {
assertEquals(state.statusEmoji, ':test2:');
assertEquals(state.statusExpiration, 820630800);
assertEquals(state.dndDurationMinutes, 0);
assertArrayIncludes([
'EEEEEE',
'AAAAAAH',
], [state.statusText]);
});

// await duration(1000);
} catch (error) {
throw error;
} finally {
console.log('cleanup');
helpers.cleanup();
}
});

async function basicTestSetup(
scheduleTomlFile: string,
) {
// Setup stubs (clean after test)
const time = createFixedTimepoint(2, 1, 1996);
const fetchStub = stubFetch();
const readFileStub = stubReadTextFile();

// Create fake secret and stub
readFileStub.set(`/run/secrets/slack_status_scheduler_user_token`, 'slack_status_scheduler_user_token');
// Create toml schedule representation and stub
readFileStub.set('/schedule.toml', scheduleTomlFile);

// create mock Slack api
const slackApi = createSlackActionsApi(fetchStub);

// load runtime as import module
const runtimeModule = await import('../core/runtime.ts');
const runtime = runtimeModule.createRuntimeScope({ crashOnException: true });

return {
runtime,
time,
fetchStub,
slackApi,
readFileStub,
cleanup() {
// runtime?.stop();
time.restore();
fetchStub.cleanup();
readFileStub.cleanup();
},
};
}
117 changes: 117 additions & 0 deletions tests/internal/common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { setTimeout } from './time.ts';

/**
* Creates a simple promise
* @param data
* @returns
*/
export function createSimplePromise<ResolveData>(
data: ResolveData,
): Promise<ResolveData> {
return new Promise((resolve) => resolve(data));
}

/**
* Creates a shallow clone (useful in assertions) using JSON internals
* @param jsonFriendlyData The data to clone
* @returns The replicated object
*/
export function shallowClone<DataType = unknown>(jsonFriendlyData: DataType): DataType {
return JSON.parse(JSON.stringify(jsonFriendlyData));
}

type DataMapKeyType = string | RegExp | undefined;

/**
* Checks a string against a DataMapKey (string or Regexp)
* @param value The value to test
* @param testAgainst The test conditions (string as direct, regexp as well - regexp)
* @returns The test result
*/
export function stringMatchesKey(value: string, testAgainst: DataMapKeyType): boolean {
return (testAgainst instanceof RegExp && testAgainst.test(value)) ||
(value === testAgainst);
}

/**
* Iterate through the data map and check if any keys are matches (processes regular expressions)
* This function, for the most part, only accounts for functions that are string based (no fancy post processing)
* @param keyToCheck
* @returns
*/
export function getDataFromKey<MapDataType>(
dataMap: Map<DataMapKeyType, MapDataType>,
keyToCheck: string,
): MapDataType | null {
for (const [key, value] of dataMap) {
if (stringMatchesKey(keyToCheck, key)) {
return value;
}
}

return null;
}

/**
* Parses JSON if it can, otherwise returns null. Useful for fetch interceptors.
* @param probablyJSONValue
* @returns
*/
export function jsonParseOrNull(probablyJSONValue: string) {
try {
return JSON.parse(probablyJSONValue);
} catch (_e) {
// noop
}
return null;
}

/**
* Wait a minimum amount of time on the event loop
* @param timeInMs
* @returns
*/
export function duration(timeInMs: number) {
return new Promise((resolve) => setTimeout(() => resolve(null), timeInMs));
}

/**
* @unused
* Creates a handler for capturing async errors on the event loop that suddenly
* stop propagating. At the end of tests, this gets executed.
* @returns
*/
export function _captureEventLoopErrors() {
const eventLoopErrors: Error[] = [];

function onUnhandledRejection(e: PromiseRejectionEvent) {
// Track the error
eventLoopErrors.push(e.reason as Error);
// Don't allow other code to track this exception
e.preventDefault();
e.stopImmediatePropagation();
}

globalThis.addEventListener('unhandledrejection', onUnhandledRejection);

return {
check() {
if (!eventLoopErrors.length) return;

console.error(`(${eventLoopErrors.length}) rejections detected`);

eventLoopErrors.forEach((error, errorIndex) => {
console.error(`(${errorIndex})`);
console.error(error);
});

throw new Error(`(${eventLoopErrors.length}) rejections detected`);
},
cleanup() {
// remove the handler
globalThis.removeEventListener('unhandledrejection', onUnhandledRejection);
// nudge gc
eventLoopErrors.length = 0;
},
};
}
4 changes: 4 additions & 0 deletions tests/internal/deps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { FakeTime } from 'https://deno.land/[email protected]/testing/time.ts';
export { _internals as FakeTimeInternals } from 'https://deno.land/[email protected]/testing/_time.ts';

export { assertArrayIncludes, assertEquals } from 'https://deno.land/[email protected]/assert/mod.ts';
Loading

0 comments on commit b86bf85

Please sign in to comment.