effect-libreoffice 1.0.0
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/.github/workflows/ci.yml +40 -0
- package/.vscode/settings.json +3 -0
- package/Dockerfile +12 -0
- package/README.md +82 -0
- package/biome.json +43 -0
- package/compose.yml +19 -0
- package/docker/ubuntu.Dockerfile +3 -0
- package/examples/cloud-run/compose.yml +5 -0
- package/examples/cloud-run/uno.Dockerfile +9 -0
- package/fixtures/test.txt +7 -0
- package/package.json +50 -0
- package/src/index.test.ts +126 -0
- package/src/index.ts +180 -0
- package/src/misc/benchmark.ts +116 -0
- package/src/misc/scratchpad.ts +26 -0
- package/src/shared.ts +30 -0
- package/src/ubuntu-docker.test.ts +61 -0
- package/src/uno/schema-utils.test.ts +58 -0
- package/src/uno/schema-utils.ts +44 -0
- package/src/uno/uno-response.ts +102 -0
- package/src/uno/uno.test.ts +192 -0
- package/src/uno/uno.ts +226 -0
- package/src/uno/xml-parser.ts +16 -0
- package/tsconfig.json +25 -0
- package/tsdown.config.ts +9 -0
- package/vitest.config.ts +9 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { NodeContext, NodeHttpClient } from "@effect/platform-node";
|
|
4
|
+
import { Effect, Layer } from "effect";
|
|
5
|
+
import { Bench } from "tinybench";
|
|
6
|
+
import { LibreOffice } from "../index";
|
|
7
|
+
import { UnoClient, UnoServer } from "../uno/uno";
|
|
8
|
+
|
|
9
|
+
const bench = new Bench({ time: 5000 }); // Run for 5 seconds
|
|
10
|
+
|
|
11
|
+
// Setup paths
|
|
12
|
+
const fixturesDir = path.resolve(__dirname, "../fixtures");
|
|
13
|
+
const outputDir = path.resolve(__dirname, "../tmp/benchmark");
|
|
14
|
+
const inputFile = path.join(fixturesDir, "test.txt");
|
|
15
|
+
|
|
16
|
+
if (!fs.existsSync(outputDir)) {
|
|
17
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Ensure input file exists
|
|
21
|
+
if (!fs.existsSync(inputFile)) {
|
|
22
|
+
console.error(`Input file not found: ${inputFile}`);
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Setup shared volume for Uno
|
|
27
|
+
const sharedDir = "/tmp/test-convert";
|
|
28
|
+
const sharedInputFile = path.join(sharedDir, "test.txt");
|
|
29
|
+
if (!fs.existsSync(sharedDir)) {
|
|
30
|
+
console.error(`Shared directory not found: ${sharedDir}`);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
fs.copyFileSync(inputFile, sharedInputFile);
|
|
34
|
+
|
|
35
|
+
const outputDefault = path.join(outputDir, "output_default.pdf");
|
|
36
|
+
const outputUno = path.join(sharedDir, "output_uno.pdf");
|
|
37
|
+
|
|
38
|
+
// 1. Default Implementation (CLI)
|
|
39
|
+
// Needs: NodeContext (for FileSystem, Path)
|
|
40
|
+
// AND the resulting effect needs CommandExecutor which comes from NodeContext
|
|
41
|
+
const DefaultEnv = LibreOffice.Default.pipe(
|
|
42
|
+
Layer.provideMerge(NodeContext.layer),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const runDefault = Effect.gen(function* () {
|
|
46
|
+
const lo = yield* LibreOffice;
|
|
47
|
+
yield* lo.convertLocalFile(inputFile, outputDefault);
|
|
48
|
+
}).pipe(Effect.provide(DefaultEnv));
|
|
49
|
+
|
|
50
|
+
// 2. Uno Implementation (Remote)
|
|
51
|
+
// Needs: UnoClient, UnoServer.Remote, NodeContext, NodeHttpClient
|
|
52
|
+
const UnoEnv = LibreOffice.Uno.pipe(
|
|
53
|
+
Layer.provide(UnoClient.Default),
|
|
54
|
+
Layer.provide(UnoServer.Remote),
|
|
55
|
+
Layer.provideMerge(NodeHttpClient.layer),
|
|
56
|
+
Layer.provideMerge(NodeContext.layer),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const runUno = Effect.gen(function* () {
|
|
60
|
+
const lo = yield* LibreOffice;
|
|
61
|
+
yield* lo.convertLocalFile(sharedInputFile, outputUno);
|
|
62
|
+
}).pipe(Effect.provide(UnoEnv));
|
|
63
|
+
|
|
64
|
+
const runUnoParallel = Effect.gen(function* () {
|
|
65
|
+
const lo = yield* LibreOffice;
|
|
66
|
+
yield* Effect.all(
|
|
67
|
+
Array.from({ length: 6 }, (_, i) =>
|
|
68
|
+
lo.convertLocalFile(
|
|
69
|
+
sharedInputFile,
|
|
70
|
+
path.join(sharedDir, `output_uno_p${i}.pdf`),
|
|
71
|
+
),
|
|
72
|
+
),
|
|
73
|
+
{ concurrency: "unbounded" },
|
|
74
|
+
);
|
|
75
|
+
}).pipe(Effect.provide(UnoEnv));
|
|
76
|
+
|
|
77
|
+
const main = async () => {
|
|
78
|
+
console.log("Starting benchmark...");
|
|
79
|
+
console.log(`Input: ${inputFile}`);
|
|
80
|
+
console.log("Warming up...");
|
|
81
|
+
|
|
82
|
+
// Verify they work first to avoid silent failures in bench
|
|
83
|
+
try {
|
|
84
|
+
await Effect.runPromise(runDefault);
|
|
85
|
+
console.log("Default implementation verified.");
|
|
86
|
+
} catch (e) {
|
|
87
|
+
console.error("Default implementation failed:", e);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
await Effect.runPromise(runUno);
|
|
93
|
+
console.log("Uno implementation verified.");
|
|
94
|
+
} catch (e) {
|
|
95
|
+
console.error("Uno implementation failed:", e);
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
bench
|
|
100
|
+
.add("Default (CLI)", async () => {
|
|
101
|
+
await Effect.runPromise(runDefault);
|
|
102
|
+
})
|
|
103
|
+
.add("Uno (Remote)", async () => {
|
|
104
|
+
await Effect.runPromise(runUno);
|
|
105
|
+
})
|
|
106
|
+
.add("Uno (Remote) - Parallel", async () => {
|
|
107
|
+
await Effect.runPromise(runUnoParallel);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
console.log("Running benchmark...");
|
|
111
|
+
await bench.run();
|
|
112
|
+
|
|
113
|
+
console.table(bench.table());
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
main().catch(console.error);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Command } from "@effect/platform";
|
|
2
|
+
import { NodeContext, NodeRuntime } from "@effect/platform-node";
|
|
3
|
+
import { Effect } from "effect";
|
|
4
|
+
|
|
5
|
+
const compose = Command.make("docker", "compose", "up").pipe(
|
|
6
|
+
Command.stdout("inherit"),
|
|
7
|
+
Command.stderr("inherit"),
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
const program = Effect.gen(function* () {
|
|
11
|
+
// const process = yield* Command.start(compose);
|
|
12
|
+
|
|
13
|
+
yield* Effect.acquireRelease(Command.start(compose), (process) =>
|
|
14
|
+
Effect.ignore(process.kill("SIGTERM")),
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
yield* Effect.log("hello");
|
|
18
|
+
yield* Effect.sleep("10 seconds");
|
|
19
|
+
yield* Effect.log("world");
|
|
20
|
+
|
|
21
|
+
// yield* process.kill();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
NodeRuntime.runMain(
|
|
25
|
+
program.pipe(Effect.scoped, Effect.provide(NodeContext.layer)),
|
|
26
|
+
);
|
package/src/shared.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Data } from "effect";
|
|
2
|
+
|
|
3
|
+
export type KnownSupportedOutputFormat =
|
|
4
|
+
| "pdf"
|
|
5
|
+
| "docx"
|
|
6
|
+
| "doc"
|
|
7
|
+
| "odt"
|
|
8
|
+
| "html"
|
|
9
|
+
| "rtf"
|
|
10
|
+
| "epub"
|
|
11
|
+
| "jpg"
|
|
12
|
+
| "txt";
|
|
13
|
+
|
|
14
|
+
export type OutputPath =
|
|
15
|
+
| `${string}.${KnownSupportedOutputFormat}`
|
|
16
|
+
| (string & {});
|
|
17
|
+
|
|
18
|
+
type Reason =
|
|
19
|
+
| "InputFileNotFound"
|
|
20
|
+
| "StartFailed"
|
|
21
|
+
| "Unknown"
|
|
22
|
+
| "BadOutputExtension"
|
|
23
|
+
| "MethodNotFound"
|
|
24
|
+
| "PermissionDenied";
|
|
25
|
+
|
|
26
|
+
export class LibreOfficeError extends Data.TaggedError("LibreOfficeError")<{
|
|
27
|
+
reason: Reason;
|
|
28
|
+
message: string;
|
|
29
|
+
cause?: unknown;
|
|
30
|
+
}> {}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Path } from "@effect/platform";
|
|
2
|
+
import { NodeContext } from "@effect/platform-node";
|
|
3
|
+
import { assert, expect, it } from "@effect/vitest";
|
|
4
|
+
import { Effect, Layer, Predicate } from "effect";
|
|
5
|
+
import { GenericContainer } from "testcontainers";
|
|
6
|
+
import { LibreOffice, LibreOfficeCmd } from "./index";
|
|
7
|
+
|
|
8
|
+
const UbuntuContainer = Layer.scoped(
|
|
9
|
+
LibreOfficeCmd,
|
|
10
|
+
Effect.gen(function* () {
|
|
11
|
+
const path = yield* Path.Path;
|
|
12
|
+
const dockerfilePath = path.join(process.cwd(), "docker");
|
|
13
|
+
|
|
14
|
+
const container = yield* Effect.acquireRelease(
|
|
15
|
+
Effect.promise(async () => {
|
|
16
|
+
const image = await GenericContainer.fromDockerfile(
|
|
17
|
+
dockerfilePath,
|
|
18
|
+
"ubuntu.Dockerfile",
|
|
19
|
+
).build("fiws/effect-libreoffice-ubuntu:latest", {
|
|
20
|
+
deleteOnExit: false,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
return await image
|
|
24
|
+
.withReuse()
|
|
25
|
+
.withCommand(["tail", "-f", "/dev/null"])
|
|
26
|
+
.start();
|
|
27
|
+
}),
|
|
28
|
+
(container) => Effect.promise(() => container.stop()),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
return ["docker", "exec", container.getId(), "soffice", "--headless"];
|
|
32
|
+
}),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const TestLive = LibreOffice.Default.pipe(
|
|
36
|
+
Layer.provideMerge(UbuntuContainer),
|
|
37
|
+
Layer.provideMerge(NodeContext.layer),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
it.layer(TestLive, { timeout: 120_000 })(
|
|
41
|
+
"Libreoffice (Ubuntu Docker)",
|
|
42
|
+
(it) => {
|
|
43
|
+
it.effect(
|
|
44
|
+
"should fails with source file not found",
|
|
45
|
+
Effect.fn(function* () {
|
|
46
|
+
const libre = yield* LibreOffice;
|
|
47
|
+
const result = yield* libre
|
|
48
|
+
.convertLocalFile("./fixtures/test-not-found.txt", "test.out.pdf")
|
|
49
|
+
.pipe(Effect.flip);
|
|
50
|
+
|
|
51
|
+
expect(result._tag).toBe("LibreOfficeError");
|
|
52
|
+
|
|
53
|
+
assert(
|
|
54
|
+
Predicate.isTagged(result, "LibreOfficeError"),
|
|
55
|
+
"result is not LibreOfficeError",
|
|
56
|
+
);
|
|
57
|
+
expect(result.reason).toBe("InputFileNotFound");
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
60
|
+
},
|
|
61
|
+
);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { StructFromMembers } from "./schema-utils.js";
|
|
4
|
+
|
|
5
|
+
describe("StructFromMembers", () => {
|
|
6
|
+
it("decodes valid members to struct", () => {
|
|
7
|
+
const Target = StructFromMembers({
|
|
8
|
+
count: Schema.Number,
|
|
9
|
+
message: Schema.String,
|
|
10
|
+
optional: Schema.optional(Schema.String),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const input = [
|
|
14
|
+
{ name: "count", value: { int: 42 } },
|
|
15
|
+
{ name: "message", value: { string: "hello" } },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const result = Schema.decodeSync(Target)(input);
|
|
19
|
+
expect(result).toEqual({ count: 42, message: "hello" });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("encodes struct to members", () => {
|
|
23
|
+
const Target = StructFromMembers({
|
|
24
|
+
count: Schema.Number,
|
|
25
|
+
message: Schema.String,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const input = { count: 42, message: "hello" };
|
|
29
|
+
const result = Schema.encodeSync(Target)(input);
|
|
30
|
+
|
|
31
|
+
// Sort to ensure order independence in test, though map preserves order
|
|
32
|
+
const sorted = [...result].sort((a, b) => a.name.localeCompare(b.name));
|
|
33
|
+
|
|
34
|
+
expect(sorted).toEqual([
|
|
35
|
+
{ name: "count", value: { int: 42 } },
|
|
36
|
+
{ name: "message", value: { string: "hello" } },
|
|
37
|
+
]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("fails decode on mismatched types", () => {
|
|
41
|
+
const Target = StructFromMembers({
|
|
42
|
+
count: Schema.Number, // Expects number
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const input = [{ name: "count", value: { string: "not a number" } }];
|
|
46
|
+
|
|
47
|
+
expect(() => Schema.decodeSync(Target)(input)).toThrow();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("fails decode on missing required fields", () => {
|
|
51
|
+
const Target = StructFromMembers({
|
|
52
|
+
required: Schema.String,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const input: unknown[] = [];
|
|
56
|
+
expect(() => Schema.decodeUnknownSync(Target)(input)).toThrow();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
const MemberValue = Schema.Union(
|
|
4
|
+
Schema.Struct({ int: Schema.Number }),
|
|
5
|
+
Schema.Struct({ string: Schema.String }),
|
|
6
|
+
);
|
|
7
|
+
|
|
8
|
+
const Member = Schema.Struct({
|
|
9
|
+
name: Schema.String,
|
|
10
|
+
value: MemberValue,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const Members = Schema.Array(Member);
|
|
14
|
+
|
|
15
|
+
export const StructFromMembers = <Fields extends Schema.Struct.Fields>(
|
|
16
|
+
fields: Fields,
|
|
17
|
+
) =>
|
|
18
|
+
Schema.transform(Members, Schema.Struct(fields), {
|
|
19
|
+
strict: false,
|
|
20
|
+
decode: (input) => {
|
|
21
|
+
const output: Record<string, string | number> = {};
|
|
22
|
+
for (const member of input) {
|
|
23
|
+
if ("int" in member.value) {
|
|
24
|
+
output[member.name] = member.value.int;
|
|
25
|
+
} else if ("string" in member.value) {
|
|
26
|
+
output[member.name] = member.value.string;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return output;
|
|
30
|
+
},
|
|
31
|
+
encode: (input) => {
|
|
32
|
+
return Object.entries(input).map(([name, value]) => {
|
|
33
|
+
if (typeof value === "number") {
|
|
34
|
+
return { name, value: { int: value } };
|
|
35
|
+
}
|
|
36
|
+
if (typeof value === "string") {
|
|
37
|
+
return { name, value: { string: value } };
|
|
38
|
+
}
|
|
39
|
+
throw new Error(
|
|
40
|
+
`Unsupported value type for member ${name}: ${typeof value}`,
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
},
|
|
44
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
import { StructFromMembers } from "./schema-utils";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Schema for fault response
|
|
7
|
+
*
|
|
8
|
+
* ## Original xml
|
|
9
|
+
* ```xml
|
|
10
|
+
* <?xml version='1.0'?>
|
|
11
|
+
* <methodResponse>
|
|
12
|
+
* <fault>
|
|
13
|
+
* <value>
|
|
14
|
+
* <struct>
|
|
15
|
+
* <member>
|
|
16
|
+
* <name>faultCode</name>
|
|
17
|
+
* <value>
|
|
18
|
+
* <int>1</int>
|
|
19
|
+
* </value>
|
|
20
|
+
* </member>
|
|
21
|
+
* <member>
|
|
22
|
+
* <name>faultString</name>
|
|
23
|
+
* <value>
|
|
24
|
+
* <string><class 'RuntimeError'>:Path /tmp/test-convert/test.txt does not exist.</string>
|
|
25
|
+
* </value>
|
|
26
|
+
* </member>
|
|
27
|
+
* </struct>
|
|
28
|
+
* </value>
|
|
29
|
+
* </fault>
|
|
30
|
+
* </methodResponse>
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
const UnoFault = Schema.Struct({
|
|
34
|
+
methodResponse: Schema.Struct({
|
|
35
|
+
fault: Schema.Struct({
|
|
36
|
+
value: Schema.Struct({
|
|
37
|
+
struct: Schema.Struct({
|
|
38
|
+
member: StructFromMembers({
|
|
39
|
+
faultCode: Schema.Number,
|
|
40
|
+
faultString: Schema.String,
|
|
41
|
+
}),
|
|
42
|
+
}),
|
|
43
|
+
}),
|
|
44
|
+
}),
|
|
45
|
+
}),
|
|
46
|
+
}).pipe(
|
|
47
|
+
Schema.transform(
|
|
48
|
+
Schema.Struct({
|
|
49
|
+
faultCode: Schema.Number,
|
|
50
|
+
faultString: Schema.String,
|
|
51
|
+
}),
|
|
52
|
+
{
|
|
53
|
+
strict: true,
|
|
54
|
+
decode: (input) => input.methodResponse.fault.value.struct.member,
|
|
55
|
+
encode: (input) => ({
|
|
56
|
+
methodResponse: {
|
|
57
|
+
fault: {
|
|
58
|
+
value: {
|
|
59
|
+
struct: {
|
|
60
|
+
member: input,
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
}),
|
|
66
|
+
},
|
|
67
|
+
),
|
|
68
|
+
Schema.asSchema,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Schema for empty response (success)
|
|
73
|
+
*
|
|
74
|
+
* ## Original xml
|
|
75
|
+
* ```xml
|
|
76
|
+
* <?xml version='1.0'?>
|
|
77
|
+
* <methodResponse>
|
|
78
|
+
* <params>
|
|
79
|
+
* <param>
|
|
80
|
+
* <value>
|
|
81
|
+
* <nil />
|
|
82
|
+
* </value>
|
|
83
|
+
* </param>
|
|
84
|
+
* </params>
|
|
85
|
+
* </methodResponse>
|
|
86
|
+
* ```
|
|
87
|
+
*/
|
|
88
|
+
export const UnoEmpty = Schema.Struct({
|
|
89
|
+
methodResponse: Schema.Struct({
|
|
90
|
+
params: Schema.Struct({
|
|
91
|
+
param: Schema.Struct({
|
|
92
|
+
value: Schema.Struct({
|
|
93
|
+
nil: Schema.String,
|
|
94
|
+
}),
|
|
95
|
+
}),
|
|
96
|
+
}),
|
|
97
|
+
}),
|
|
98
|
+
}).pipe(Schema.asSchema);
|
|
99
|
+
|
|
100
|
+
export const UnoResponse = Schema.Union(UnoFault, UnoEmpty);
|
|
101
|
+
|
|
102
|
+
export const decodeUnoResponse = Schema.decodeUnknown(UnoResponse);
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { FileSystem, Path } from "@effect/platform";
|
|
2
|
+
import { NodeContext, NodeHttpClient } from "@effect/platform-node";
|
|
3
|
+
import { assert, expect, it } from "@effect/vitest";
|
|
4
|
+
import { Effect, Layer, Predicate } from "effect";
|
|
5
|
+
import { GenericContainer, Wait } from "testcontainers";
|
|
6
|
+
import { LibreOffice } from "../index";
|
|
7
|
+
import { testRunning, UnoClient, UnoServer } from "./uno";
|
|
8
|
+
|
|
9
|
+
class TempDir extends Effect.Service<TempDir>()(
|
|
10
|
+
"libre-convert-effect/uno/uno.test/TempDir",
|
|
11
|
+
{
|
|
12
|
+
scoped: Effect.gen(function* () {
|
|
13
|
+
const fs = yield* FileSystem.FileSystem;
|
|
14
|
+
return {
|
|
15
|
+
dir: yield* fs.makeTempDirectoryScoped(),
|
|
16
|
+
};
|
|
17
|
+
}),
|
|
18
|
+
},
|
|
19
|
+
) {}
|
|
20
|
+
|
|
21
|
+
const UnoServerTest = Layer.scoped(
|
|
22
|
+
UnoServer,
|
|
23
|
+
Effect.gen(function* () {
|
|
24
|
+
const { dir: tempDir } = yield* TempDir;
|
|
25
|
+
const container = yield* Effect.acquireRelease(
|
|
26
|
+
Effect.tryPromise(async () => {
|
|
27
|
+
const image = await GenericContainer.fromDockerfile(".").build(
|
|
28
|
+
"fiws/libreoffice-unoserver:latest",
|
|
29
|
+
{
|
|
30
|
+
deleteOnExit: false,
|
|
31
|
+
},
|
|
32
|
+
);
|
|
33
|
+
return await image
|
|
34
|
+
.withExposedPorts(2003)
|
|
35
|
+
.withUser(
|
|
36
|
+
`${process.getuid ? process.getuid() : 1000}:${process.getgid ? process.getgid() : 1000}`,
|
|
37
|
+
)
|
|
38
|
+
.withBindMounts([{ source: tempDir, target: tempDir }])
|
|
39
|
+
.withEnvironment({ HOME: tempDir })
|
|
40
|
+
.withReuse()
|
|
41
|
+
.withWaitStrategy(
|
|
42
|
+
Wait.forLogMessage(/INFO:unoserver:Started./).withStartupTimeout(
|
|
43
|
+
120_000,
|
|
44
|
+
),
|
|
45
|
+
)
|
|
46
|
+
.start();
|
|
47
|
+
}),
|
|
48
|
+
(c) => Effect.promise(() => c.stop()),
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const port = container.getMappedPort(2003);
|
|
52
|
+
|
|
53
|
+
yield* testRunning(`http://localhost:${port}/RPC2`);
|
|
54
|
+
return UnoServer.make({
|
|
55
|
+
url: `http://localhost:${port}/RPC2`,
|
|
56
|
+
});
|
|
57
|
+
}),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const UnoLayer = LibreOffice.Uno.pipe(
|
|
61
|
+
Layer.provide(UnoClient.Default),
|
|
62
|
+
Layer.provideMerge(UnoServerTest),
|
|
63
|
+
Layer.provideMerge(TempDir.Default),
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const TestLive = Layer.provideMerge(
|
|
67
|
+
UnoLayer,
|
|
68
|
+
Layer.merge(NodeContext.layer, NodeHttpClient.layer),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
it.layer(TestLive, { timeout: 120_000 })("Libreoffice (Uno)", (it) => {
|
|
72
|
+
it.scoped(
|
|
73
|
+
"should convert a file",
|
|
74
|
+
Effect.fn(function* () {
|
|
75
|
+
const fs = yield* FileSystem.FileSystem;
|
|
76
|
+
const path = yield* Path.Path;
|
|
77
|
+
const libre = yield* LibreOffice;
|
|
78
|
+
|
|
79
|
+
const tempDir = yield* fs.makeTempDirectory({
|
|
80
|
+
directory: (yield* TempDir).dir,
|
|
81
|
+
});
|
|
82
|
+
const sourceFile = path.join(tempDir, "test.txt");
|
|
83
|
+
const targetFile = path.join(tempDir, "test.out.pdf");
|
|
84
|
+
|
|
85
|
+
yield* fs.writeFileString(sourceFile, "Hello PDF");
|
|
86
|
+
yield* libre.convertLocalFile(sourceFile, targetFile);
|
|
87
|
+
|
|
88
|
+
const targetContent = yield* fs.readFile(targetFile);
|
|
89
|
+
|
|
90
|
+
const header = new TextDecoder().decode(targetContent.slice(0, 4));
|
|
91
|
+
expect(header).toBe("%PDF");
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
it.effect(
|
|
96
|
+
"should fails with source file not found",
|
|
97
|
+
Effect.fn(function* () {
|
|
98
|
+
const libre = yield* LibreOffice;
|
|
99
|
+
const result = yield* libre
|
|
100
|
+
.convertLocalFile("./fixtures/test-not-found.txt", "test.out.pdf")
|
|
101
|
+
.pipe(Effect.flip);
|
|
102
|
+
assert(
|
|
103
|
+
Predicate.isTagged(result, "LibreOfficeError"),
|
|
104
|
+
"result is not LibreOfficeError",
|
|
105
|
+
);
|
|
106
|
+
expect(result.reason).toBe("InputFileNotFound");
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
it.effect(
|
|
111
|
+
"Should work with 2 conversions in parallel",
|
|
112
|
+
Effect.fn(function* () {
|
|
113
|
+
const libre = yield* LibreOffice;
|
|
114
|
+
const fs = yield* FileSystem.FileSystem;
|
|
115
|
+
const path = yield* Path.Path;
|
|
116
|
+
|
|
117
|
+
const tempDir = yield* fs.makeTempDirectory({
|
|
118
|
+
directory: (yield* TempDir).dir,
|
|
119
|
+
});
|
|
120
|
+
const sourceFile = path.join(tempDir, "test.txt");
|
|
121
|
+
const targetFile = path.join(tempDir, "test.out.pdf");
|
|
122
|
+
|
|
123
|
+
yield* fs.writeFileString(sourceFile, "Hello PDF");
|
|
124
|
+
|
|
125
|
+
yield* Effect.all(
|
|
126
|
+
[
|
|
127
|
+
libre.convertLocalFile(sourceFile, targetFile),
|
|
128
|
+
libre.convertLocalFile(sourceFile, targetFile),
|
|
129
|
+
],
|
|
130
|
+
{ concurrency: "unbounded" },
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const targetContent = yield* fs.readFile(targetFile);
|
|
134
|
+
|
|
135
|
+
const header = new TextDecoder().decode(targetContent.slice(0, 4));
|
|
136
|
+
expect(header).toBe("%PDF");
|
|
137
|
+
}),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
it.effect(
|
|
141
|
+
"should fail with invalid output extension",
|
|
142
|
+
Effect.fn(function* () {
|
|
143
|
+
const libre = yield* LibreOffice;
|
|
144
|
+
const fs = yield* FileSystem.FileSystem;
|
|
145
|
+
const path = yield* Path.Path;
|
|
146
|
+
const tempDir = yield* fs.makeTempDirectory({
|
|
147
|
+
directory: (yield* TempDir).dir,
|
|
148
|
+
});
|
|
149
|
+
const sourceFile = path.join(tempDir, "test.txt");
|
|
150
|
+
const targetFile = path.join(tempDir, "test.invalidext");
|
|
151
|
+
|
|
152
|
+
yield* fs.writeFileString(sourceFile, "Hello PDF");
|
|
153
|
+
|
|
154
|
+
const result = yield* libre
|
|
155
|
+
.convertLocalFile(sourceFile, targetFile)
|
|
156
|
+
.pipe(Effect.flip);
|
|
157
|
+
|
|
158
|
+
assert(
|
|
159
|
+
Predicate.isTagged(result, "LibreOfficeError"),
|
|
160
|
+
"result is not LibreOfficeError",
|
|
161
|
+
);
|
|
162
|
+
expect(result.reason).toBe("BadOutputExtension");
|
|
163
|
+
}),
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
it.effect(
|
|
167
|
+
"should fail with output as directory",
|
|
168
|
+
Effect.fn(function* () {
|
|
169
|
+
const libre = yield* LibreOffice;
|
|
170
|
+
const fs = yield* FileSystem.FileSystem;
|
|
171
|
+
const path = yield* Path.Path;
|
|
172
|
+
const tempDir = yield* fs.makeTempDirectory({
|
|
173
|
+
directory: (yield* TempDir).dir,
|
|
174
|
+
});
|
|
175
|
+
const sourceFile = path.join(tempDir, "test.txt");
|
|
176
|
+
// Try to write to a directory
|
|
177
|
+
const targetFile = tempDir;
|
|
178
|
+
|
|
179
|
+
yield* fs.writeFileString(sourceFile, "Hello PDF");
|
|
180
|
+
|
|
181
|
+
const result = yield* libre
|
|
182
|
+
.convertLocalFile(sourceFile, targetFile)
|
|
183
|
+
.pipe(Effect.flip);
|
|
184
|
+
|
|
185
|
+
assert(
|
|
186
|
+
Predicate.isTagged(result, "LibreOfficeError"),
|
|
187
|
+
"result is not LibreOfficeError",
|
|
188
|
+
);
|
|
189
|
+
expect(result.reason).toBe("BadOutputExtension");
|
|
190
|
+
}),
|
|
191
|
+
);
|
|
192
|
+
});
|