xref: /trunk/test/aoo-test/src/aootest/office.py (revision 2cb6d6f119bbe2f78742f1c5c8538cb0d4e72ede)
1################################################################
2#
3#  Licensed to the Apache Software Foundation (ASF) under one
4#  or more contributor license agreements.  See the NOTICE file
5#  distributed with this work for additional information
6#  regarding copyright ownership.  The ASF licenses this file
7#  to you under the Apache License, Version 2.0 (the
8#  "License"); you may not use this file except in compliance
9#  with the License.  You may obtain a copy of the License at
10#
11#    http://www.apache.org/licenses/LICENSE-2.0
12#
13#  Unless required by applicable law or agreed to in writing,
14#  software distributed under the License is distributed on an
15#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
16#  KIND, either express or implied.  See the License for the
17#  specific language governing permissions and limitations
18#  under the License.
19#
20################################################################
21
22"""Starting the office, its profile, and the session that restarts it after a hang or crash."""
23import socket
24import uuid
25from pathlib import Path
26
27from aootest import proc
28from aootest.client import ProtocolError, WorkerCrash, WorkerHang
29
30XCU_HEAD = ('<?xml version="1.0" encoding="UTF-8"?>\n<oor:items xmlns:oor="http://openoffice.org/2001/registry" '
31            'xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">\n')
32
33
34def registry_xcu(default_version=None, extra=None):
35    """registrymodifications.xcu content. `default_version` is Save/ODF/DefaultVersion (3 = ODF 1.2 extended, 4 = 1.2, 5 = 1.3,
36    6 = 1.3 extended; 0 leaves the product default). `extra` maps other Save/ODF boolean properties (UseSHA1InODF12, ...) to values."""
37    values = {}
38    if default_version is not None:
39        values["DefaultVersion"] = "%d" % default_version
40    for name, v in (extra or {}).items():
41        values[name] = ("true" if v else "false") if isinstance(v, bool) else str(v)
42    items = "".join('<item oor:path="/org.openoffice.Office.Common/Save/ODF"><prop oor:name="%s" oor:op="fuse">'
43                    '<value>%s</value></prop></item>\n' % kv for kv in values.items())
44    return XCU_HEAD + items + "</oor:items>\n"
45
46
47def make_profile(directory, default_version=None, extra=None):
48    """Creates a user profile in `directory` (empty or absent; nothing is deleted) and returns its file URL."""
49    d = Path(directory).resolve()
50    if d.is_dir() and any(d.iterdir()):
51        raise ValueError("profile directory is not empty: %s" % d)
52    (d / "user").mkdir(parents=True, exist_ok=True)
53    (d / "user" / "registrymodifications.xcu").write_text(registry_xcu(default_version, extra), encoding="utf-8")
54    return d.as_uri()
55
56
57def free_port():
58    with socket.socket() as s:
59        s.bind(("127.0.0.1", 0))
60        return s.getsockname()[1]
61
62
63def accept_string(connect="pipe", name=None, port=None):
64    """The -accept= value: a uniquely named pipe, or a socket on `port` (a free one by default)."""
65    if connect == "pipe":
66        return "pipe,name=%s;urp;" % (name or "aootest_" + uuid.uuid4().hex[:12])
67    if connect == "socket":
68        return "socket,host=localhost,port=%d;urp;" % (port or free_port())
69    raise ValueError("connect must be 'pipe' or 'socket'")
70
71
72def command(layout, profile_url, accept, headless=True):
73    """soffice command line. AOO takes single-dash options only and ignores `--headless`."""
74    return ([str(layout.soffice)] + (["-headless"] if headless else []) +
75            ["-invisible", "-nofirststartwizard", "-norestore", "-nologo", "-nodefault", "-nolockcheck",
76             "-env:UserInstallation=" + profile_url, "-accept=" + accept])
77
78
79class Office:
80    """A running soffice. Readiness is the worker connecting to it, so there is nothing to poll here."""
81
82    def __init__(self, cmd, env=None, log=None):
83        self.cmd, self.env, self.log, self._proc = list(cmd), env, log, None
84
85    def start(self):
86        out = open(self.log, "ab") if self.log else None
87        try:
88            self._proc = proc.spawn(self.cmd, env=self.env, stdin=None, stdout=out, stderr=out)
89        finally:
90            if out:
91                out.close()
92        return self
93
94    @property
95    def pid(self):
96        return self._proc.pid if self._proc else None
97
98    def alive(self):
99        return self._proc is not None and self._proc.poll() is None
100
101    def kill(self):
102        if self._proc is not None:
103            proc.kill_tree(self._proc)
104
105
106class SessionAborted(Exception):
107    pass
108
109
110class OfficeSession:
111    """One office and one worker, started lazily. A hang, crash or protocol error tears both down, is re-raised to the
112    caller, and the next call starts fresh; after more than `max_restarts` such failures the session is aborted.
113
114    start_office() returns an object with kill(); start_worker(office) returns a started client with call() and close()."""
115
116    def __init__(self, start_office, start_worker, max_restarts=5):
117        self._start_office, self._start_worker, self.max_restarts = start_office, start_worker, max_restarts
118        self.office = self.worker = None
119        self.failures = 0
120        self.aborted = False
121
122    def _ensure(self):
123        if self.aborted:
124            raise SessionAborted("aborted after %d failures (limit %d)" % (self.failures, self.max_restarts))
125        if self.worker is None:
126            self.office = self._start_office()
127            try:
128                self.worker = self._start_worker(self.office)
129            except BaseException as e:
130                self._teardown()
131                if isinstance(e, (WorkerHang, WorkerCrash, ProtocolError)):
132                    self._failed()
133                raise
134
135    def _failed(self):
136        self.failures += 1
137        if self.failures > self.max_restarts:
138            self.aborted = True
139
140    def _teardown(self):
141        w, o, self.worker, self.office = self.worker, self.office, None, None
142        if w is not None:
143            w.close()
144        if o is not None:
145            o.kill()
146
147    def call(self, op, args=None, timeout=60.0):
148        self._ensure()
149        try:
150            return self.worker.call(op, args, timeout)
151        except (WorkerHang, WorkerCrash, ProtocolError):
152            self._teardown()
153            self._failed()
154            raise
155
156    def close(self):
157        self._teardown()
158