@forgehive/forge-cli 0.2.9 → 0.2.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/runner.js +20 -0
- package/dist/tasks/fixture/download.d.ts +34 -0
- package/dist/tasks/fixture/download.js +106 -0
- package/dist/tasks/init.js +1 -1
- package/dist/tasks/task/replay.d.ts +47 -0
- package/dist/tasks/task/replay.js +172 -0
- package/forge.json +9 -1
- package/package.json +7 -7
- package/src/runner.ts +22 -0
- package/src/tasks/fixture/download.ts +143 -0
- package/src/tasks/init.ts +1 -1
- package/src/tasks/task/replay.ts +211 -0
package/dist/runner.js
CHANGED
|
@@ -18,6 +18,8 @@ const remove_2 = require("./tasks/runner/remove");
|
|
|
18
18
|
const bundle_1 = require("./tasks/runner/bundle");
|
|
19
19
|
const publish_1 = require("./tasks/task/publish");
|
|
20
20
|
const download_1 = require("./tasks/task/download");
|
|
21
|
+
const replay_1 = require("./tasks/task/replay");
|
|
22
|
+
const download_2 = require("./tasks/fixture/download");
|
|
21
23
|
const add_1 = require("./tasks/auth/add");
|
|
22
24
|
const switch_1 = require("./tasks/auth/switch");
|
|
23
25
|
const list_1 = require("./tasks/auth/list");
|
|
@@ -39,10 +41,13 @@ runner.load('task:run', run_1.run);
|
|
|
39
41
|
runner.load('task:remove', remove_1.remove);
|
|
40
42
|
runner.load('task:publish', publish_1.publish);
|
|
41
43
|
runner.load('task:download', download_1.download);
|
|
44
|
+
runner.load('task:replay', replay_1.replay);
|
|
42
45
|
// Runner commands
|
|
43
46
|
runner.load('runner:create', create_1.create);
|
|
44
47
|
runner.load('runner:remove', remove_2.remove);
|
|
45
48
|
runner.load('runner:bundle', bundle_1.bundle);
|
|
49
|
+
// Fixture commands
|
|
50
|
+
runner.load('fixture:download', download_2.download);
|
|
46
51
|
// Auth commands
|
|
47
52
|
runner.load('auth:add', add_1.add);
|
|
48
53
|
runner.load('auth:switch', switch_1.switchProfile);
|
|
@@ -86,12 +91,27 @@ runner.setHandler(async (data) => {
|
|
|
86
91
|
uuid
|
|
87
92
|
});
|
|
88
93
|
}
|
|
94
|
+
else if (taskName === 'task:replay') {
|
|
95
|
+
const { path, cache } = args;
|
|
96
|
+
result = await task.run({
|
|
97
|
+
descriptorName: action,
|
|
98
|
+
path,
|
|
99
|
+
cache
|
|
100
|
+
});
|
|
101
|
+
}
|
|
89
102
|
else if (taskName === 'task:run') {
|
|
90
103
|
result = await task.run({
|
|
91
104
|
descriptorName: action,
|
|
92
105
|
args
|
|
93
106
|
});
|
|
94
107
|
}
|
|
108
|
+
else if (taskName === 'fixture:download') {
|
|
109
|
+
const { uuid } = args;
|
|
110
|
+
result = await task.run({
|
|
111
|
+
descriptorName: action,
|
|
112
|
+
uuid
|
|
113
|
+
});
|
|
114
|
+
}
|
|
95
115
|
else if (taskName === 'auth:add') {
|
|
96
116
|
const { apiKey, apiSecret, url } = args;
|
|
97
117
|
result = await task.run({
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Profile } from '../types';
|
|
2
|
+
interface FixtureData {
|
|
3
|
+
name: string;
|
|
4
|
+
[key: string]: unknown;
|
|
5
|
+
}
|
|
6
|
+
interface FixtureResponse {
|
|
7
|
+
fixture: FixtureData;
|
|
8
|
+
[key: string]: unknown;
|
|
9
|
+
}
|
|
10
|
+
export declare const download: import("@forgehive/task").TaskInstanceType<(argv: {
|
|
11
|
+
uuid: string;
|
|
12
|
+
}, boundaries: import("@forgehive/task").WrappedBoundaries<{
|
|
13
|
+
loadCurrentProfile: (args: {}) => Promise<Promise<Profile>>;
|
|
14
|
+
loadConf: (args: {}) => Promise<Promise<import("../types").ForgeConf>>;
|
|
15
|
+
downloadFixture: (uuid: string, profile: Profile) => Promise<FixtureResponse>;
|
|
16
|
+
getCwd: () => Promise<string>;
|
|
17
|
+
persistFixture: (filePath: string, data: FixtureData) => Promise<{
|
|
18
|
+
path: string;
|
|
19
|
+
}>;
|
|
20
|
+
checkFileExists: (filePath: string) => Promise<boolean>;
|
|
21
|
+
}>) => Promise<{
|
|
22
|
+
status: string;
|
|
23
|
+
path: string;
|
|
24
|
+
}>, {
|
|
25
|
+
loadCurrentProfile: (args: {}) => Promise<Promise<Profile>>;
|
|
26
|
+
loadConf: (args: {}) => Promise<Promise<import("../types").ForgeConf>>;
|
|
27
|
+
downloadFixture: (uuid: string, profile: Profile) => Promise<FixtureResponse>;
|
|
28
|
+
getCwd: () => Promise<string>;
|
|
29
|
+
persistFixture: (filePath: string, data: FixtureData) => Promise<{
|
|
30
|
+
path: string;
|
|
31
|
+
}>;
|
|
32
|
+
checkFileExists: (filePath: string) => Promise<boolean>;
|
|
33
|
+
}>;
|
|
34
|
+
export {};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// TASK: download
|
|
3
|
+
// Run this task with:
|
|
4
|
+
// forge task:run fixture:download --uuid [fixture-uuid]
|
|
5
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
6
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
7
|
+
};
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.download = void 0;
|
|
10
|
+
const task_1 = require("@forgehive/task");
|
|
11
|
+
const schema_1 = require("@forgehive/schema");
|
|
12
|
+
const axios_1 = __importDefault(require("axios"));
|
|
13
|
+
const path_1 = __importDefault(require("path"));
|
|
14
|
+
const promises_1 = __importDefault(require("fs/promises"));
|
|
15
|
+
const loadCurrent_1 = require("../auth/loadCurrent");
|
|
16
|
+
const load_1 = require("../conf/load");
|
|
17
|
+
const description = 'Download a fixture by UUID to a path based on task descriptor returned from API';
|
|
18
|
+
const schema = new schema_1.Schema({
|
|
19
|
+
uuid: schema_1.Schema.string()
|
|
20
|
+
});
|
|
21
|
+
const boundaries = {
|
|
22
|
+
loadCurrentProfile: loadCurrent_1.loadCurrent.asBoundary(),
|
|
23
|
+
loadConf: load_1.load.asBoundary(),
|
|
24
|
+
downloadFixture: async (uuid, profile) => {
|
|
25
|
+
const downloadUrl = `${profile.url}/api/fixture/${uuid}`;
|
|
26
|
+
console.log(`Downloading fixture from ${downloadUrl}...`);
|
|
27
|
+
const authToken = `${profile.apiKey}:${profile.apiSecret}`;
|
|
28
|
+
const response = await axios_1.default.get(downloadUrl, {
|
|
29
|
+
headers: {
|
|
30
|
+
Authorization: `Bearer ${authToken}`,
|
|
31
|
+
'Content-Type': 'application/json'
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
return response.data;
|
|
35
|
+
},
|
|
36
|
+
getCwd: async () => {
|
|
37
|
+
return process.cwd();
|
|
38
|
+
},
|
|
39
|
+
persistFixture: async (filePath, data) => {
|
|
40
|
+
const dirPath = path_1.default.dirname(filePath);
|
|
41
|
+
await promises_1.default.mkdir(dirPath, { recursive: true });
|
|
42
|
+
await promises_1.default.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8');
|
|
43
|
+
return {
|
|
44
|
+
path: filePath
|
|
45
|
+
};
|
|
46
|
+
},
|
|
47
|
+
checkFileExists: async (filePath) => {
|
|
48
|
+
try {
|
|
49
|
+
await promises_1.default.access(filePath);
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
exports.download = (0, task_1.createTask)(schema, boundaries, async function ({ uuid }, { downloadFixture, getCwd, persistFixture, checkFileExists, loadCurrentProfile, loadConf }) {
|
|
58
|
+
console.log('==================================================');
|
|
59
|
+
console.log(`Attempting to download fixture with uuid: ${uuid}`);
|
|
60
|
+
const profile = await loadCurrentProfile({});
|
|
61
|
+
const cwd = await getCwd();
|
|
62
|
+
const forge = await loadConf({});
|
|
63
|
+
// Download from hive api server first to get the task descriptor
|
|
64
|
+
let response;
|
|
65
|
+
try {
|
|
66
|
+
response = await downloadFixture(uuid, profile);
|
|
67
|
+
}
|
|
68
|
+
catch (e) {
|
|
69
|
+
const error = e;
|
|
70
|
+
console.error('Error downloading fixture:', error.message, error.status);
|
|
71
|
+
if (error.status === 404) {
|
|
72
|
+
throw new Error('Fixture not found');
|
|
73
|
+
}
|
|
74
|
+
throw new Error('Failed to download fixture');
|
|
75
|
+
}
|
|
76
|
+
// Extract task descriptor from the response
|
|
77
|
+
const taskName = response.fixture.name;
|
|
78
|
+
// Determine the output path using forge fixtures path and task descriptor
|
|
79
|
+
const fixturesBasePath = forge.paths.fixtures || 'fixtures';
|
|
80
|
+
const fixtureDir = path_1.default.join(fixturesBasePath, taskName);
|
|
81
|
+
const fixturePath = path_1.default.join(fixtureDir, `${uuid}.json`);
|
|
82
|
+
const filePath = path_1.default.resolve(cwd, fixturePath);
|
|
83
|
+
console.log(`Fixture will be saved to: ${filePath}`);
|
|
84
|
+
// Check if file already exists
|
|
85
|
+
const fileExists = await checkFileExists(filePath);
|
|
86
|
+
if (fileExists) {
|
|
87
|
+
console.log(`Fixture will be updated at ${filePath}`);
|
|
88
|
+
}
|
|
89
|
+
console.log(`
|
|
90
|
+
==================================================
|
|
91
|
+
Starting fixture download!
|
|
92
|
+
Fixture UUID: ${uuid}
|
|
93
|
+
Task Name: ${taskName}
|
|
94
|
+
Saving to: ${filePath}
|
|
95
|
+
==================================================
|
|
96
|
+
Replay with: forge task:replay --path ${filePath}
|
|
97
|
+
==================================================
|
|
98
|
+
`);
|
|
99
|
+
// Persist fixture to file
|
|
100
|
+
await persistFixture(filePath, response.fixture);
|
|
101
|
+
return {
|
|
102
|
+
status: 'Downloaded',
|
|
103
|
+
path: filePath
|
|
104
|
+
};
|
|
105
|
+
});
|
|
106
|
+
exports.download.setDescription(description);
|
package/dist/tasks/init.js
CHANGED
|
@@ -31,9 +31,9 @@ exports.init = (0, task_1.createTask)(schema, boundaries, async function (argv,
|
|
|
31
31
|
},
|
|
32
32
|
paths: {
|
|
33
33
|
logs: 'logs/',
|
|
34
|
+
fixtures: 'fixtures/',
|
|
34
35
|
tasks: 'src/tasks/',
|
|
35
36
|
runners: 'src/runners/',
|
|
36
|
-
fixtures: 'src/tests/fixtures',
|
|
37
37
|
tests: 'src/tests/'
|
|
38
38
|
},
|
|
39
39
|
infra: {
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type ForgeConf, type Profile } from '../types';
|
|
2
|
+
interface Fixture {
|
|
3
|
+
fixtureUUID: string;
|
|
4
|
+
name: string;
|
|
5
|
+
type: 'success' | 'error';
|
|
6
|
+
input: Record<string, unknown>;
|
|
7
|
+
output: Record<string, unknown>;
|
|
8
|
+
boundaries: Record<string, unknown>;
|
|
9
|
+
context: Record<string, unknown>;
|
|
10
|
+
}
|
|
11
|
+
export declare const replay: import("@forgehive/task").TaskInstanceType<(argv: {
|
|
12
|
+
path: string;
|
|
13
|
+
cache?: string | undefined;
|
|
14
|
+
}, boundaries: import("@forgehive/task").WrappedBoundaries<{
|
|
15
|
+
readFixture: (filePath: string) => Promise<Fixture>;
|
|
16
|
+
loadConf: (args: {}) => Promise<Promise<ForgeConf>>;
|
|
17
|
+
loadCurrentProfile: (args: {}) => Promise<Promise<Profile>>;
|
|
18
|
+
bundleCreate: (args: {
|
|
19
|
+
entryPoint: string;
|
|
20
|
+
outputFile: string;
|
|
21
|
+
}) => Promise<Promise<{
|
|
22
|
+
outputFile: string;
|
|
23
|
+
}>>;
|
|
24
|
+
bundleLoad: (args: {
|
|
25
|
+
bundlePath: string;
|
|
26
|
+
}) => Promise<Promise<any>>;
|
|
27
|
+
ensureBuildsFolder: () => Promise<string>;
|
|
28
|
+
verifyLogFolder: (logsPath: string) => Promise<boolean>;
|
|
29
|
+
sendLogToAPI: (profile: Profile, projectName: string, taskName: string, logItem: unknown, fixtureUUID: string) => Promise<boolean>;
|
|
30
|
+
}>) => Promise<any>, {
|
|
31
|
+
readFixture: (filePath: string) => Promise<Fixture>;
|
|
32
|
+
loadConf: (args: {}) => Promise<Promise<ForgeConf>>;
|
|
33
|
+
loadCurrentProfile: (args: {}) => Promise<Promise<Profile>>;
|
|
34
|
+
bundleCreate: (args: {
|
|
35
|
+
entryPoint: string;
|
|
36
|
+
outputFile: string;
|
|
37
|
+
}) => Promise<Promise<{
|
|
38
|
+
outputFile: string;
|
|
39
|
+
}>>;
|
|
40
|
+
bundleLoad: (args: {
|
|
41
|
+
bundlePath: string;
|
|
42
|
+
}) => Promise<Promise<any>>;
|
|
43
|
+
ensureBuildsFolder: () => Promise<string>;
|
|
44
|
+
verifyLogFolder: (logsPath: string) => Promise<boolean>;
|
|
45
|
+
sendLogToAPI: (profile: Profile, projectName: string, taskName: string, logItem: unknown, fixtureUUID: string) => Promise<boolean>;
|
|
46
|
+
}>;
|
|
47
|
+
export {};
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// TASK: replay
|
|
3
|
+
// Run this task with:
|
|
4
|
+
// forge task:run task:replay
|
|
5
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
6
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
7
|
+
};
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.replay = void 0;
|
|
10
|
+
const task_1 = require("@forgehive/task");
|
|
11
|
+
const schema_1 = require("@forgehive/schema");
|
|
12
|
+
const promises_1 = __importDefault(require("fs/promises"));
|
|
13
|
+
const path_1 = __importDefault(require("path"));
|
|
14
|
+
const os_1 = __importDefault(require("os"));
|
|
15
|
+
const axios_1 = __importDefault(require("axios"));
|
|
16
|
+
const create_1 = require("../bundle/create");
|
|
17
|
+
const load_1 = require("../bundle/load");
|
|
18
|
+
const load_2 = require("../conf/load");
|
|
19
|
+
const loadCurrent_1 = require("../auth/loadCurrent");
|
|
20
|
+
const description = 'Replay a task execution from a specified path';
|
|
21
|
+
const schema = new schema_1.Schema({
|
|
22
|
+
path: schema_1.Schema.string(),
|
|
23
|
+
cache: schema_1.Schema.string().optional()
|
|
24
|
+
});
|
|
25
|
+
const boundaries = {
|
|
26
|
+
readFixture: async (filePath) => {
|
|
27
|
+
const fileContent = await promises_1.default.readFile(filePath, 'utf-8');
|
|
28
|
+
const parsedData = JSON.parse(fileContent);
|
|
29
|
+
return parsedData;
|
|
30
|
+
},
|
|
31
|
+
loadConf: load_2.load.asBoundary(),
|
|
32
|
+
loadCurrentProfile: loadCurrent_1.loadCurrent.asBoundary(),
|
|
33
|
+
bundleCreate: create_1.create.asBoundary(),
|
|
34
|
+
bundleLoad: load_1.load.asBoundary(),
|
|
35
|
+
ensureBuildsFolder: async () => {
|
|
36
|
+
const buildsPath = path_1.default.join(os_1.default.homedir(), '.forge', 'builds');
|
|
37
|
+
try {
|
|
38
|
+
await promises_1.default.access(buildsPath);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
await promises_1.default.mkdir(buildsPath, { recursive: true });
|
|
42
|
+
}
|
|
43
|
+
return buildsPath;
|
|
44
|
+
},
|
|
45
|
+
verifyLogFolder: async (logsPath) => {
|
|
46
|
+
// return true if the folder exists
|
|
47
|
+
try {
|
|
48
|
+
await promises_1.default.access(logsPath);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
return true;
|
|
54
|
+
},
|
|
55
|
+
sendLogToAPI: async (profile, projectName, taskName, logItem, fixtureUUID) => {
|
|
56
|
+
try {
|
|
57
|
+
const logsUrl = `${profile.url}/api/tasks/log-ingest`;
|
|
58
|
+
const authToken = `${profile.apiKey}:${profile.apiSecret}`;
|
|
59
|
+
await axios_1.default.post(logsUrl, {
|
|
60
|
+
projectName,
|
|
61
|
+
taskName,
|
|
62
|
+
logItem: JSON.stringify(logItem),
|
|
63
|
+
replayFrom: fixtureUUID
|
|
64
|
+
}, {
|
|
65
|
+
headers: {
|
|
66
|
+
Authorization: `Bearer ${authToken}`,
|
|
67
|
+
'Content-Type': 'application/json'
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
console.log('===============================================');
|
|
71
|
+
console.log('Log sent to API... ', profile.name, profile.url);
|
|
72
|
+
console.log('Replay from fixture UUID:', fixtureUUID);
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
catch (e) {
|
|
76
|
+
const error = e;
|
|
77
|
+
console.error('Failed to send log to API:', error.message);
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
exports.replay = (0, task_1.createTask)(schema, boundaries, async function (argv, { readFixture, loadConf, loadCurrentProfile, bundleCreate, bundleLoad, ensureBuildsFolder, verifyLogFolder, sendLogToAPI }) {
|
|
83
|
+
console.log('Input path:', argv.path);
|
|
84
|
+
// Read the file from the provided path
|
|
85
|
+
const fixture = await readFixture(argv.path);
|
|
86
|
+
// Load forge configuration
|
|
87
|
+
const forge = await loadConf({});
|
|
88
|
+
const taskName = fixture.name;
|
|
89
|
+
const taskDescriptor = forge.tasks[taskName];
|
|
90
|
+
const projectName = forge.project.name;
|
|
91
|
+
if (taskDescriptor === undefined) {
|
|
92
|
+
throw new Error(`Task ${taskName} is not defined in forge.json`);
|
|
93
|
+
}
|
|
94
|
+
// Try to load profile, but continue if not found
|
|
95
|
+
let profile = null;
|
|
96
|
+
try {
|
|
97
|
+
profile = await loadCurrentProfile({});
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
// Profile not found or not configured, continue without it
|
|
101
|
+
console.log('No profile found, logs will not be sent to remote API');
|
|
102
|
+
}
|
|
103
|
+
// Verify if log folder exists
|
|
104
|
+
const logFolderPath = path_1.default.join(process.cwd(), forge.paths.logs);
|
|
105
|
+
const logFolderExists = await verifyLogFolder(logFolderPath);
|
|
106
|
+
if (!logFolderExists) {
|
|
107
|
+
throw new Error(`Log folder "${logFolderPath}" does not exist`);
|
|
108
|
+
}
|
|
109
|
+
// Prepare paths
|
|
110
|
+
const entryPoint = path_1.default.join(process.cwd(), taskDescriptor.path);
|
|
111
|
+
const buildsPath = await ensureBuildsFolder();
|
|
112
|
+
const outputFile = path_1.default.join(buildsPath, `${taskName}.js`);
|
|
113
|
+
// Bundle the task
|
|
114
|
+
await bundleCreate({
|
|
115
|
+
entryPoint,
|
|
116
|
+
outputFile
|
|
117
|
+
});
|
|
118
|
+
// Load the bundled task
|
|
119
|
+
const bundle = await bundleLoad({
|
|
120
|
+
bundlePath: outputFile
|
|
121
|
+
});
|
|
122
|
+
// Get the task handler
|
|
123
|
+
const task = bundle[taskDescriptor.handler];
|
|
124
|
+
if (!task) {
|
|
125
|
+
throw new Error(`Handler "${taskDescriptor.handler}" not found in bundle`);
|
|
126
|
+
}
|
|
127
|
+
// Configure boundaries based on cache parameter if provided
|
|
128
|
+
const boundaryConfig = {};
|
|
129
|
+
if (argv.cache) {
|
|
130
|
+
// Parse the comma-separated list and trim each item
|
|
131
|
+
const cacheBoundaries = argv.cache.split(',').map(b => b.trim());
|
|
132
|
+
// Log which boundaries will use cache mode
|
|
133
|
+
if (cacheBoundaries.length > 0) {
|
|
134
|
+
// Set each specified boundary to 'replay' mode
|
|
135
|
+
cacheBoundaries.forEach(boundary => {
|
|
136
|
+
boundaryConfig[boundary] = 'replay';
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
console.log('==================================================');
|
|
141
|
+
console.log('UUID:', fixture.fixtureUUID);
|
|
142
|
+
console.log('Name:', fixture.name);
|
|
143
|
+
console.log('Context:', fixture.context);
|
|
144
|
+
console.log('==================================================');
|
|
145
|
+
console.log('Replay:', fixture.input);
|
|
146
|
+
console.log('Boundaries:', JSON.stringify(fixture.boundaries, null, 2));
|
|
147
|
+
console.log('==================================================');
|
|
148
|
+
console.log('Boundary config:', boundaryConfig);
|
|
149
|
+
console.log('==================================================');
|
|
150
|
+
// Perform the replay
|
|
151
|
+
const [result, error, record] = await task.safeReplay({
|
|
152
|
+
input: fixture.input,
|
|
153
|
+
output: fixture.output,
|
|
154
|
+
boundaries: fixture.boundaries,
|
|
155
|
+
}, {
|
|
156
|
+
boundaries: boundaryConfig // Use configured boundary modes
|
|
157
|
+
});
|
|
158
|
+
// Send the log to API if profile is available
|
|
159
|
+
if (profile) {
|
|
160
|
+
try {
|
|
161
|
+
await sendLogToAPI(profile, projectName, taskName, record, fixture.fixtureUUID);
|
|
162
|
+
}
|
|
163
|
+
catch (e) {
|
|
164
|
+
console.error('Failed to send log to API:', e);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (error) {
|
|
168
|
+
throw new Error(error.message);
|
|
169
|
+
}
|
|
170
|
+
return result;
|
|
171
|
+
});
|
|
172
|
+
exports.replay.setDescription(description);
|
package/forge.json
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
},
|
|
5
5
|
"paths": {
|
|
6
6
|
"logs": "logs/",
|
|
7
|
+
"fixtures": "fixtures",
|
|
7
8
|
"tasks": "src/tasks/",
|
|
8
9
|
"runners": "src/runners/",
|
|
9
|
-
"fixtures": "src/tests/fixtures",
|
|
10
10
|
"tests": "src/tests/"
|
|
11
11
|
},
|
|
12
12
|
"infra": {
|
|
@@ -77,6 +77,14 @@
|
|
|
77
77
|
"auth:remove": {
|
|
78
78
|
"path": "src/tasks/auth/remove.ts",
|
|
79
79
|
"handler": "remove"
|
|
80
|
+
},
|
|
81
|
+
"task:replay": {
|
|
82
|
+
"path": "src/tasks/task/replay.ts",
|
|
83
|
+
"handler": "replay"
|
|
84
|
+
},
|
|
85
|
+
"fixture:download": {
|
|
86
|
+
"path": "src/tasks/fixture/download.ts",
|
|
87
|
+
"handler": "download"
|
|
80
88
|
}
|
|
81
89
|
},
|
|
82
90
|
"runners": {}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forgehive/forge-cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.10",
|
|
4
4
|
"description": "TypeScript CLI application",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -10,10 +10,10 @@
|
|
|
10
10
|
"publishConfig": {
|
|
11
11
|
"access": "public",
|
|
12
12
|
"dependencies": {
|
|
13
|
-
"@forgehive/record-tape": "^0.1.
|
|
14
|
-
"@forgehive/runner": "^0.1.
|
|
13
|
+
"@forgehive/record-tape": "^0.1.3",
|
|
14
|
+
"@forgehive/runner": "^0.1.9",
|
|
15
15
|
"@forgehive/schema": "^0.1.4",
|
|
16
|
-
"@forgehive/task": "^0.1.
|
|
16
|
+
"@forgehive/task": "^0.1.9",
|
|
17
17
|
"esbuild": "^0.25.0",
|
|
18
18
|
"handlebars": "^4.7.8",
|
|
19
19
|
"minimist": "^1.2.8"
|
|
@@ -25,10 +25,10 @@
|
|
|
25
25
|
"esbuild": "^0.25.0",
|
|
26
26
|
"handlebars": "^4.7.8",
|
|
27
27
|
"minimist": "^1.2.8",
|
|
28
|
-
"@forgehive/runner": "0.1.
|
|
29
|
-
"@forgehive/record-tape": "0.1.2",
|
|
28
|
+
"@forgehive/runner": "0.1.9",
|
|
30
29
|
"@forgehive/schema": "0.1.4",
|
|
31
|
-
"@forgehive/task": "0.1.
|
|
30
|
+
"@forgehive/task": "0.1.9",
|
|
31
|
+
"@forgehive/record-tape": "0.1.3"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"@types/jest": "^29.5.3",
|
package/src/runner.ts
CHANGED
|
@@ -18,6 +18,9 @@ import { remove as removeRunner } from './tasks/runner/remove'
|
|
|
18
18
|
import { bundle as bundleRunner } from './tasks/runner/bundle'
|
|
19
19
|
import { publish as publishTask } from './tasks/task/publish'
|
|
20
20
|
import { download as downloadTask } from './tasks/task/download'
|
|
21
|
+
import { replay as replayTask } from './tasks/task/replay'
|
|
22
|
+
|
|
23
|
+
import { download as downloadFixture } from './tasks/fixture/download'
|
|
21
24
|
|
|
22
25
|
import { add as addProfile } from './tasks/auth/add'
|
|
23
26
|
import { switchProfile } from './tasks/auth/switch'
|
|
@@ -48,12 +51,16 @@ runner.load('task:run', taskRunCommand)
|
|
|
48
51
|
runner.load('task:remove', taskRemoveCommand)
|
|
49
52
|
runner.load('task:publish', publishTask)
|
|
50
53
|
runner.load('task:download', downloadTask)
|
|
54
|
+
runner.load('task:replay', replayTask)
|
|
51
55
|
|
|
52
56
|
// Runner commands
|
|
53
57
|
runner.load('runner:create', createRunner)
|
|
54
58
|
runner.load('runner:remove', removeRunner)
|
|
55
59
|
runner.load('runner:bundle', bundleRunner)
|
|
56
60
|
|
|
61
|
+
// Fixture commands
|
|
62
|
+
runner.load('fixture:download', downloadFixture)
|
|
63
|
+
|
|
57
64
|
// Auth commands
|
|
58
65
|
runner.load('auth:add', addProfile)
|
|
59
66
|
runner.load('auth:switch', switchProfile)
|
|
@@ -101,11 +108,26 @@ runner.setHandler(async (data: ParsedArgs): Promise<unknown> => {
|
|
|
101
108
|
descriptorName: action,
|
|
102
109
|
uuid
|
|
103
110
|
})
|
|
111
|
+
} else if (taskName === 'task:replay') {
|
|
112
|
+
const { path, cache } = args as { path: string, cache: string }
|
|
113
|
+
|
|
114
|
+
result = await task.run({
|
|
115
|
+
descriptorName: action,
|
|
116
|
+
path,
|
|
117
|
+
cache
|
|
118
|
+
})
|
|
104
119
|
} else if (taskName === 'task:run') {
|
|
105
120
|
result = await task.run({
|
|
106
121
|
descriptorName: action,
|
|
107
122
|
args
|
|
108
123
|
})
|
|
124
|
+
} else if (taskName === 'fixture:download') {
|
|
125
|
+
const { uuid } = args as { uuid: string }
|
|
126
|
+
|
|
127
|
+
result = await task.run({
|
|
128
|
+
descriptorName: action,
|
|
129
|
+
uuid
|
|
130
|
+
})
|
|
109
131
|
} else if (taskName === 'auth:add') {
|
|
110
132
|
const { apiKey, apiSecret, url } = args as { name: string, apiKey: string, apiSecret: string, url: string }
|
|
111
133
|
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// TASK: download
|
|
2
|
+
// Run this task with:
|
|
3
|
+
// forge task:run fixture:download --uuid [fixture-uuid]
|
|
4
|
+
|
|
5
|
+
import { createTask } from '@forgehive/task'
|
|
6
|
+
import { Schema } from '@forgehive/schema'
|
|
7
|
+
import axios from 'axios'
|
|
8
|
+
import path from 'path'
|
|
9
|
+
import fs from 'fs/promises'
|
|
10
|
+
import { loadCurrent as loadCurrentProfile } from '../auth/loadCurrent'
|
|
11
|
+
import { load as loadConf } from '../conf/load'
|
|
12
|
+
import { Profile } from '../types'
|
|
13
|
+
|
|
14
|
+
// Define the Fixture data structure
|
|
15
|
+
interface FixtureData {
|
|
16
|
+
name: string;
|
|
17
|
+
[key: string]: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface FixtureResponse {
|
|
21
|
+
fixture: FixtureData;
|
|
22
|
+
[key: string]: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const description = 'Download a fixture by UUID to a path based on task descriptor returned from API'
|
|
26
|
+
|
|
27
|
+
const schema = new Schema({
|
|
28
|
+
uuid: Schema.string()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const boundaries = {
|
|
32
|
+
loadCurrentProfile: loadCurrentProfile.asBoundary(),
|
|
33
|
+
loadConf: loadConf.asBoundary(),
|
|
34
|
+
downloadFixture: async (uuid: string, profile: Profile): Promise<FixtureResponse> => {
|
|
35
|
+
const downloadUrl = `${profile.url}/api/fixture/${uuid}`
|
|
36
|
+
|
|
37
|
+
console.log(`Downloading fixture from ${downloadUrl}...`)
|
|
38
|
+
|
|
39
|
+
const authToken = `${profile.apiKey}:${profile.apiSecret}`
|
|
40
|
+
const response = await axios.get(downloadUrl, {
|
|
41
|
+
headers: {
|
|
42
|
+
Authorization: `Bearer ${authToken}`,
|
|
43
|
+
'Content-Type': 'application/json'
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
return response.data
|
|
48
|
+
},
|
|
49
|
+
getCwd: async (): Promise<string> => {
|
|
50
|
+
return process.cwd()
|
|
51
|
+
},
|
|
52
|
+
persistFixture: async (filePath: string, data: FixtureData): Promise<{ path: string }> => {
|
|
53
|
+
const dirPath = path.dirname(filePath)
|
|
54
|
+
|
|
55
|
+
await fs.mkdir(dirPath, { recursive: true })
|
|
56
|
+
await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8')
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
path: filePath
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
checkFileExists: async (filePath: string): Promise<boolean> => {
|
|
63
|
+
try {
|
|
64
|
+
await fs.access(filePath)
|
|
65
|
+
return true
|
|
66
|
+
} catch {
|
|
67
|
+
return false
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const download = createTask(
|
|
73
|
+
schema,
|
|
74
|
+
boundaries,
|
|
75
|
+
async function ({ uuid }, {
|
|
76
|
+
downloadFixture,
|
|
77
|
+
getCwd,
|
|
78
|
+
persistFixture,
|
|
79
|
+
checkFileExists,
|
|
80
|
+
loadCurrentProfile,
|
|
81
|
+
loadConf
|
|
82
|
+
}) {
|
|
83
|
+
console.log('==================================================')
|
|
84
|
+
console.log(`Attempting to download fixture with uuid: ${uuid}`)
|
|
85
|
+
|
|
86
|
+
const profile = await loadCurrentProfile({})
|
|
87
|
+
const cwd = await getCwd()
|
|
88
|
+
const forge = await loadConf({})
|
|
89
|
+
|
|
90
|
+
// Download from hive api server first to get the task descriptor
|
|
91
|
+
let response
|
|
92
|
+
try {
|
|
93
|
+
response = await downloadFixture(uuid, profile)
|
|
94
|
+
} catch (e: unknown) {
|
|
95
|
+
const error = e as { status?: number, message: string }
|
|
96
|
+
console.error('Error downloading fixture:', error.message, error.status)
|
|
97
|
+
|
|
98
|
+
if (error.status === 404) {
|
|
99
|
+
throw new Error('Fixture not found')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
throw new Error('Failed to download fixture')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Extract task descriptor from the response
|
|
106
|
+
const taskName = response.fixture.name
|
|
107
|
+
|
|
108
|
+
// Determine the output path using forge fixtures path and task descriptor
|
|
109
|
+
const fixturesBasePath = forge.paths.fixtures || 'fixtures'
|
|
110
|
+
const fixtureDir = path.join(fixturesBasePath, taskName)
|
|
111
|
+
const fixturePath = path.join(fixtureDir, `${uuid}.json`)
|
|
112
|
+
const filePath = path.resolve(cwd, fixturePath)
|
|
113
|
+
|
|
114
|
+
console.log(`Fixture will be saved to: ${filePath}`)
|
|
115
|
+
|
|
116
|
+
// Check if file already exists
|
|
117
|
+
const fileExists = await checkFileExists(filePath)
|
|
118
|
+
if (fileExists) {
|
|
119
|
+
console.log(`Fixture will be updated at ${filePath}`)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
console.log(`
|
|
123
|
+
==================================================
|
|
124
|
+
Starting fixture download!
|
|
125
|
+
Fixture UUID: ${uuid}
|
|
126
|
+
Task Name: ${taskName}
|
|
127
|
+
Saving to: ${filePath}
|
|
128
|
+
==================================================
|
|
129
|
+
Replay with: forge task:replay --path ${filePath}
|
|
130
|
+
==================================================
|
|
131
|
+
`)
|
|
132
|
+
|
|
133
|
+
// Persist fixture to file
|
|
134
|
+
await persistFixture(filePath, response.fixture)
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
status: 'Downloaded',
|
|
138
|
+
path: filePath
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
download.setDescription(description)
|
package/src/tasks/init.ts
CHANGED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// TASK: replay
|
|
2
|
+
// Run this task with:
|
|
3
|
+
// forge task:run task:replay
|
|
4
|
+
|
|
5
|
+
import { createTask } from '@forgehive/task'
|
|
6
|
+
import { Schema } from '@forgehive/schema'
|
|
7
|
+
import fs from 'fs/promises'
|
|
8
|
+
import path from 'path'
|
|
9
|
+
import os from 'os'
|
|
10
|
+
import axios from 'axios'
|
|
11
|
+
|
|
12
|
+
import { create as bundleCreate } from '../bundle/create'
|
|
13
|
+
import { load as bundleLoad } from '../bundle/load'
|
|
14
|
+
import { load as loadConf } from '../conf/load'
|
|
15
|
+
import { loadCurrent as loadCurrentProfile } from '../auth/loadCurrent'
|
|
16
|
+
import { type ForgeConf, type Profile } from '../types'
|
|
17
|
+
|
|
18
|
+
// Define the fixture structure type
|
|
19
|
+
interface Fixture {
|
|
20
|
+
fixtureUUID: string;
|
|
21
|
+
name: string;
|
|
22
|
+
type: 'success' | 'error';
|
|
23
|
+
input: Record<string, unknown>;
|
|
24
|
+
output: Record<string, unknown>;
|
|
25
|
+
boundaries: Record<string, unknown>;
|
|
26
|
+
context: Record<string, unknown>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const description = 'Replay a task execution from a specified path'
|
|
30
|
+
|
|
31
|
+
const schema = new Schema({
|
|
32
|
+
path: Schema.string(),
|
|
33
|
+
cache: Schema.string().optional()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const boundaries = {
|
|
37
|
+
readFixture: async (filePath: string): Promise<Fixture> => {
|
|
38
|
+
const fileContent = await fs.readFile(filePath, 'utf-8')
|
|
39
|
+
const parsedData = JSON.parse(fileContent) as Fixture
|
|
40
|
+
|
|
41
|
+
return parsedData
|
|
42
|
+
},
|
|
43
|
+
loadConf: loadConf.asBoundary(),
|
|
44
|
+
loadCurrentProfile: loadCurrentProfile.asBoundary(),
|
|
45
|
+
bundleCreate: bundleCreate.asBoundary(),
|
|
46
|
+
bundleLoad: bundleLoad.asBoundary(),
|
|
47
|
+
ensureBuildsFolder: async (): Promise<string> => {
|
|
48
|
+
const buildsPath = path.join(os.homedir(), '.forge', 'builds')
|
|
49
|
+
try {
|
|
50
|
+
await fs.access(buildsPath)
|
|
51
|
+
} catch {
|
|
52
|
+
await fs.mkdir(buildsPath, { recursive: true })
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return buildsPath
|
|
56
|
+
},
|
|
57
|
+
verifyLogFolder: async (logsPath: string): Promise<boolean> => {
|
|
58
|
+
// return true if the folder exists
|
|
59
|
+
try {
|
|
60
|
+
await fs.access(logsPath)
|
|
61
|
+
} catch (error) {
|
|
62
|
+
return false
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return true
|
|
66
|
+
},
|
|
67
|
+
sendLogToAPI: async (profile: Profile, projectName: string, taskName: string, logItem: unknown, fixtureUUID: string): Promise<boolean> => {
|
|
68
|
+
try {
|
|
69
|
+
const logsUrl = `${profile.url}/api/tasks/log-ingest`
|
|
70
|
+
const authToken = `${profile.apiKey}:${profile.apiSecret}`
|
|
71
|
+
|
|
72
|
+
await axios.post(logsUrl, {
|
|
73
|
+
projectName,
|
|
74
|
+
taskName,
|
|
75
|
+
logItem: JSON.stringify(logItem),
|
|
76
|
+
replayFrom: fixtureUUID
|
|
77
|
+
}, {
|
|
78
|
+
headers: {
|
|
79
|
+
Authorization: `Bearer ${authToken}`,
|
|
80
|
+
'Content-Type': 'application/json'
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
console.log('===============================================')
|
|
85
|
+
console.log('Log sent to API... ', profile.name, profile.url)
|
|
86
|
+
console.log('Replay from fixture UUID:', fixtureUUID)
|
|
87
|
+
|
|
88
|
+
return true
|
|
89
|
+
} catch (e) {
|
|
90
|
+
const error = e as Error
|
|
91
|
+
console.error('Failed to send log to API:', error.message)
|
|
92
|
+
return false
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const replay = createTask(
|
|
98
|
+
schema,
|
|
99
|
+
boundaries,
|
|
100
|
+
async function (argv, { readFixture, loadConf, loadCurrentProfile, bundleCreate, bundleLoad, ensureBuildsFolder, verifyLogFolder, sendLogToAPI }) {
|
|
101
|
+
console.log('Input path:', argv.path)
|
|
102
|
+
|
|
103
|
+
// Read the file from the provided path
|
|
104
|
+
const fixture = await readFixture(argv.path)
|
|
105
|
+
|
|
106
|
+
// Load forge configuration
|
|
107
|
+
const forge: ForgeConf = await loadConf({})
|
|
108
|
+
const taskName = fixture.name
|
|
109
|
+
const taskDescriptor = forge.tasks[taskName as keyof typeof forge.tasks]
|
|
110
|
+
const projectName = forge.project.name
|
|
111
|
+
|
|
112
|
+
if (taskDescriptor === undefined) {
|
|
113
|
+
throw new Error(`Task ${taskName} is not defined in forge.json`)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Try to load profile, but continue if not found
|
|
117
|
+
let profile = null
|
|
118
|
+
try {
|
|
119
|
+
profile = await loadCurrentProfile({})
|
|
120
|
+
} catch (error) {
|
|
121
|
+
// Profile not found or not configured, continue without it
|
|
122
|
+
console.log('No profile found, logs will not be sent to remote API')
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Verify if log folder exists
|
|
126
|
+
const logFolderPath = path.join(process.cwd(), forge.paths.logs)
|
|
127
|
+
const logFolderExists = await verifyLogFolder(logFolderPath)
|
|
128
|
+
if (!logFolderExists) {
|
|
129
|
+
throw new Error(`Log folder "${logFolderPath}" does not exist`)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Prepare paths
|
|
133
|
+
const entryPoint = path.join(process.cwd(), taskDescriptor.path)
|
|
134
|
+
const buildsPath = await ensureBuildsFolder()
|
|
135
|
+
const outputFile = path.join(buildsPath, `${taskName}.js`)
|
|
136
|
+
|
|
137
|
+
// Bundle the task
|
|
138
|
+
await bundleCreate({
|
|
139
|
+
entryPoint,
|
|
140
|
+
outputFile
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
// Load the bundled task
|
|
144
|
+
const bundle = await bundleLoad({
|
|
145
|
+
bundlePath: outputFile
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
// Get the task handler
|
|
149
|
+
const task = bundle[taskDescriptor.handler]
|
|
150
|
+
|
|
151
|
+
if (!task) {
|
|
152
|
+
throw new Error(`Handler "${taskDescriptor.handler}" not found in bundle`)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Configure boundaries based on cache parameter if provided
|
|
156
|
+
const boundaryConfig: Record<string, string> = {}
|
|
157
|
+
|
|
158
|
+
if (argv.cache) {
|
|
159
|
+
// Parse the comma-separated list and trim each item
|
|
160
|
+
const cacheBoundaries = argv.cache.split(',').map(b => b.trim())
|
|
161
|
+
|
|
162
|
+
// Log which boundaries will use cache mode
|
|
163
|
+
if (cacheBoundaries.length > 0) {
|
|
164
|
+
// Set each specified boundary to 'replay' mode
|
|
165
|
+
cacheBoundaries.forEach(boundary => {
|
|
166
|
+
boundaryConfig[boundary] = 'replay'
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
console.log('==================================================')
|
|
172
|
+
console.log('UUID:', fixture.fixtureUUID)
|
|
173
|
+
console.log('Name:', fixture.name)
|
|
174
|
+
console.log('Context:', fixture.context)
|
|
175
|
+
console.log('==================================================')
|
|
176
|
+
console.log('Replay:', fixture.input)
|
|
177
|
+
console.log('Boundaries:', JSON.stringify(fixture.boundaries, null, 2))
|
|
178
|
+
console.log('==================================================')
|
|
179
|
+
console.log('Boundary config:', boundaryConfig)
|
|
180
|
+
console.log('==================================================')
|
|
181
|
+
|
|
182
|
+
// Perform the replay
|
|
183
|
+
const [result, error, record] = await task.safeReplay(
|
|
184
|
+
{
|
|
185
|
+
input: fixture.input,
|
|
186
|
+
output: fixture.output,
|
|
187
|
+
boundaries: fixture.boundaries,
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
boundaries: boundaryConfig // Use configured boundary modes
|
|
191
|
+
}
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
// Send the log to API if profile is available
|
|
195
|
+
if (profile) {
|
|
196
|
+
try {
|
|
197
|
+
await sendLogToAPI(profile, projectName, taskName, record, fixture.fixtureUUID)
|
|
198
|
+
} catch (e) {
|
|
199
|
+
console.error('Failed to send log to API:', e)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (error) {
|
|
204
|
+
throw new Error(error.message)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return result
|
|
208
|
+
}
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
replay.setDescription(description)
|