Project

General

Profile

« Previous | Next » 

Revision b75a5717

Added by koszko over 1 year ago

add a repo querying HTML interface

View differences:

common/entities.js
100 100
}
101 101
#EXPORT get_newest_version AS get_newest
102 102

  
103
/*
104
 * Returns true if the argument is a nonempty array of numbers without trailing
105
 * zeros.
106
 */
107
function is_valid_version(version) {
108
    return Array.isArray(version) && version.length > 0 &&
109
	version.every(n => typeof n === "number") &&
110
	version[version.length - 1] !== 0;
111
}
112
#EXPORT is_valid_version
113

  
103 114
/*
104 115
 * item is a definition of a resource or mapping. Yield all file references
105 116
 * (objects with `file` and `sha256` properties) this definition has.
html/DOM_helpers.js
85 85
    return result_object;
86 86
}
87 87
#EXPORT clone_template
88

  
89
function Showable(on_show_cb, on_hide_cb) {
90
    this.shown = false;
91

  
92
    /*
93
     * Wrap the requested callback into one that only executes it if showable is
94
     * not shown.
95
     */
96
    this.when_hidden = cb => {
97
	const wrapped_cb = (...args) => {
98
	    if (!this.shown)
99
		return cb(...args);
100
	}
101
	return wrapped_cb;
102
    }
103

  
104
    this.show = () => {
105
	if (this.shown)
106
	    return false;
107

  
108
	this.shown = true;
109

  
110
	try {
111
	    on_show_cb();
112
	} catch(e) {
113
	    console.error(e);
114
	}
115

  
116
	return true;
117
    }
118

  
119
    this.hide = () => {
120
	if (!this.shown)
121
	    return false;
122

  
123
	this.shown = false;
124

  
125
	try {
126
	    on_hide_cb();
127
	} catch(e) {
128
	    console.error(e);
129
	}
130

  
131
	return true;
132
    }
133
}
134
#EXPORT Showable
html/base.css
88 88
    --line-height: 0.4em;
89 89
}
90 90

  
91
div.bottom_line {
91
div.top_line {
92 92
    height: var(--line-height);
93 93
    background: linear-gradient(#555, transparent);
94 94
}
95
div.top_line {
95
div.bottom_line {
96 96
    height: var(--line-height);
97 97
    background: linear-gradient(transparent, #555);
98 98
}
html/dialog.js
41 41
 * proprietary program, I am not going to enforce this in court.
42 42
 */
43 43

  
44
#FROM html/DOM_helpers.js IMPORT clone_template
44
#FROM html/DOM_helpers.js IMPORT clone_template, Showable
45 45

  
46 46
function make(on_dialog_show, on_dialog_hide)
47 47
{
48 48
    const dialog_context = clone_template("dialog");
49
    Object.assign(dialog_context, {
50
	on_dialog_show,
51
	on_dialog_hide,
52
	shown: false,
53
	queue: [],
54
    });
49
    dialog_context.queue = [];
50

  
51
    Showable.call(dialog_context, on_dialog_show, on_dialog_hide);
55 52

  
56 53
    for (const [id, val] of [["yes", true], ["no", false], ["ok", undefined]]) {
57 54
	const but = dialog_context[`${id}_but`];
......
74 71
    if (dialog_context.queue.length > 0) {
75 72
	process_queue_item(dialog_context);
76 73
    } else {
77
	dialog_context.shown = false;
78
	try {
79
	    dialog_context.on_dialog_hide();
80
	} catch(e) {
81
	    console.error(e);
82
	}
74
	dialog_context.hide();
83 75
    }
84 76

  
85 77
    resolve(event ? event.target.haketilo_dialog_result : undefined);
......
104 96
    const result_prom = new Promise(cb => resolve = cb);
105 97
    dialog_context.queue.push([shown_buts_id, msg, resolve]);
106 98

  
107
    if (!dialog_context.shown) {
99
    if (dialog_context.show())
108 100
	process_queue_item(dialog_context);
109
	dialog_context.shown = true;
110
	try {
111
	    dialog_context.on_dialog_show();
112
	} catch(e) {
113
	    console.error(e);
114
	}
115
    }
116 101

  
117 102
    return await result_prom;
118 103
}
......
129 114

  
130 115
const loader = (ctx, ...msg) => show_dialog(ctx, null, msg);
131 116
#EXPORT loader
132

  
133
/*
134
 * Wrapper the requested callback into one that only executes it if dialog is
135
 * not shown.
136
 */
137
function when_hidden(ctx, cb)
138
{
139
    function wrapped_cb(...args) {
140
	if (!ctx.shown)
141
	    return cb(...args);
142
    }
143
    return wrapped_cb;
144
}
145
#EXPORT when_hidden
html/install.html
67 67
  }
68 68

  
69 69
  .install_bottom_buttons {
70
      margin: 1em auto;
70
      margin: 1em;
71 71
      text-align: center;
72 72
  }
73 73
</style>
......
95 95
      </div>
96 96
    </div>
97 97
  </div>
98

  
98 99
  <li id="install_list_entry" data-template="main_li"
99 100
      class="install_entry_li">
100 101
    <div class="install_item_info">
html/install.js
46 46
#IMPORT html/item_preview.js AS ip
47 47

  
48 48
#FROM common/browser.js   IMPORT browser
49
#FROM html/DOM_helpers.js IMPORT clone_template
50
#FROM common/entities.js  IMPORT item_id_string, version_string, get_files
49
#FROM html/DOM_helpers.js IMPORT clone_template, Showable
50
#FROM common/entities.js  IMPORT item_id_string, version_string, get_files, \
51
                                 is_valid_version
51 52
#FROM common/misc.js      IMPORT sha256_async AS sha256
52 53

  
53 54
const coll = new Intl.Collator();
......
78 79
    }
79 80

  
80 81
    let preview_cb = () => install_view.preview_item(item.def);
81
    preview_cb = dialog.when_hidden(install_view.dialog_ctx, preview_cb);
82
    preview_cb = install_view.dialog_ctx.when_hidden(preview_cb);
82 83
    this.details_but.addEventListener("click", preview_cb);
83 84
}
84 85

  
......
114 115
}
115 116

  
116 117
function InstallView(tab_id, on_view_show, on_view_hide) {
118
    Showable.call(this, on_view_show, on_view_hide);
119

  
117 120
    Object.assign(this, clone_template("install_view"));
118
    this.shown = false;
119 121

  
120 122
    const show_container = name => {
121 123
	for (const cid of container_ids) {
......
154 156
	this[container_name].prepend(preview_ctx.main_div);
155 157
    }
156 158

  
157
    const back_cb = dialog.when_hidden(this.dialog_ctx,
158
				       () => show_container("install_preview"));
159
    let back_cb = () => show_container("install_preview");
160
    back_cb = this.dialog_ctx.when_hidden(back_cb);
159 161
    for (const type of ["resource", "mapping"])
160 162
	this[`${type}_back_but`].addEventListener("click", back_cb);
161 163

  
......
189 191
			    "Repository's response is not valid JSON :(");
190 192
	}
191 193

  
192
	if (response.json.api_schema_version > [1]) {
193
	    let api_ver = "";
194
	    try {
195
		api_ver =
196
		    ` (${version_string(response.json.api_schema_version)})`;
197
	    } catch(e) {
198
		console.warn(e);
199
	    }
194
	if (!is_valid_version(response.json.api_schema_version)) {
195
	    var bad_api_ver = "";
196
	} else if (response.json.api_schema_version > [1]) {
197
	    var bad_api_ver =
198
		` (${version_string(response.json.api_schema_version)})`;
199
	} else {
200
	    var bad_api_ver = false;
201
	}
200 202

  
203
	if (bad_api_ver !== false) {
201 204
	    const captype = item_type[0].toUpperCase() + item_type.substring(1);
202
	    const msg = `${captype} ${item_id_string(id, ver)} was served using unsupported Hydrilla API version${api_ver}. You might need to update Haketilo.`;
205
	    const msg = `${captype} ${item_id_string(id, ver)} was served using unsupported Hydrilla API version${bad_api_ver}. You might need to update Haketilo.`;
203 206
	    return work.err(null, msg);
204 207
	}
205 208

  
......
256 259
	return items;
257 260
    }
258 261

  
262
    const show_super = this.show;
259 263
    this.show = async (repo_url, item_type, item_id, item_ver) => {
260
	if (this.shown)
264
	if (!show_super())
261 265
	    return;
262 266

  
263
	this.shown = true;
264

  
265 267
	this.repo_url = repo_url;
266 268

  
267 269
	dialog.loader(this.dialog_ctx, "Fetching data from repository...");
268 270

  
269
	try {
270
	    on_view_show();
271
	} catch(e) {
272
	    console.error(e);
273
	}
274

  
275 271
	try {
276 272
	    var items = await compute_deps(item_type, item_id, item_ver);
277 273
	} catch(e) {
......
288 284

  
289 285
	    await dialog_prom;
290 286

  
291
	    hide();
287
	    this.hide();
292 288
	    return;
293 289
	}
294 290

  
......
419 415

  
420 416
	await dialog_prom;
421 417

  
422
	hide();
418
	this.hide();
423 419
    }
424 420

  
425
    const hide = () => {
426
	if (!this.shown)
421
    const hide_super = this.hide;
422
    this.hide = () => {
423
	if (!hide_super())
427 424
	    return;
428 425

  
429
	this.shown = false;
430 426
	delete this.item_entries;
431 427
	[...this.to_install_list.children].forEach(n => n.remove());
432

  
433
	try {
434
	    on_view_hide();
435
	} catch(e) {
436
	    console.error(e);
437
	}
438
    }
439

  
440
    this.when_hidden = cb => {
441
	const wrapped_cb = (...args) => {
442
	    if (!this.shown)
443
		return cb(...args);
444
	}
445
	return wrapped_cb;
446 428
    }
447 429

  
448
    const hide_cb = dialog.when_hidden(this.dialog_ctx, hide);
430
    const hide_cb = this.dialog_ctx.when_hidden(this.hide);
449 431
    this.cancel_but.addEventListener("click", hide_cb);
450 432

  
451
    const install_cb = dialog.when_hidden(this.dialog_ctx, perform_install);
433
    const install_cb = this.dialog_ctx.when_hidden(perform_install);
452 434
    this.install_but.addEventListener("click", install_cb);
453 435
}
436
#EXPORT InstallView
html/repo_query.html
1
#IF !REPO_QUERY_LOADED
2
#DEFINE REPO_QUERY_LOADED
3
<!--
4
    SPDX-License-Identifier: GPL-3.0-or-later OR CC-BY-SA-4.0
5

  
6
    Show available repositories and allow querying them for resources.
7

  
8
    This file is part of Haketilo.
9

  
10
    Copyright (C) 2022 Wojtek Kosior <koszko@koszko.org>
11

  
12
    File is dual-licensed. You can choose either GPLv3+, CC BY-SA or both.
13

  
14
    This program is free software: you can redistribute it and/or modify
15
    it under the terms of the GNU General Public License as published by
16
    the Free Software Foundation, either version 3 of the License, or
17
    (at your option) any later version.
18

  
19
    This program is distributed in the hope that it will be useful,
20
    but WITHOUT ANY WARRANTY; without even the implied warranty of
21
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
22
    GNU General Public License for more details.
23

  
24
    You should have received a copy of the GNU General Public License
25
    along with this program.  If not, see <https://www.gnu.org/licenses/>.
26

  
27
    I, Wojtek Kosior, thereby promise not to sue for violation of this file's
28
    licenses. Although I request that you do not make use of this code in a
29
    proprietary program, I am not going to enforce this in court.
30
  -->
31

  
32
<!--
33
    This is not a standalone page. This file is meant to be imported into other
34
    HTML code.
35
  -->
36

  
37
#INCLUDE html/install.html
38

  
39
#LOADCSS html/reset.css
40
#LOADCSS html/base.css
41
#LOADCSS html/grid.css
42
<style>
43
  .repo_query_top_text {
44
      text-align: center;
45
      margin: 0.4em;
46
  }
47
  .repo_queried_url {
48
      text-decoration: underline;
49
  }
50

  
51
  .repo_query_repo_li {
52
      margin: 0;
53
      background-color:#dadada;
54
  }
55
  .repo_query_repo_li > .repo_query_entry {
56
      padding: 0.2em;
57
  }
58
  .repo_query_repo_li > .repo_query_results_list {
59
      background-color: #f0f0f0;
60
  }
61

  
62
  .repo_query_result_li {
63
      margin: 0;
64
      padding: 0.2em;
65
  }
66
  .repo_query_result_li:nth-child(2n) {
67
      background-color:#dadada;
68
  }
69

  
70
  .repo_query_entry {
71
      display: flex;
72
      align-items: center;
73
  }
74
  .repo_query_entry_info {
75
      display: grid;
76
      grid-template-columns: auto;
77
      flex: 1 1 auto;
78
      min-width: 0;
79
  }
80
  .repo_query_entry_info > * {
81
      white-space: nowrap;
82
      overflow: hidden;
83
      text-overflow: ellipsis;
84
  }
85
  .repo_query_entry button {
86
      white-space: nowrap;
87
  }
88

  
89
  .repo_query_mapping_id {
90
      font-size: 80%;
91
      font-style: italic;
92
  }
93

  
94
  .repo_query_bottom_buttons {
95
      margin: 1em;
96
      text-align: center;
97
  }
98
</style>
99
<template>
100
  <div id="repo_query" data-template="main_div"
101
       class="grid_1 repo_query_main_div">
102
    <div data-template="repos_list_container">
103
      <div class="repo_query_top_text">
104
	Browsing custom resources for
105
	<span data-template="url_span" class="repo_queried_url"></span>.
106
      </div>
107
      <ul data-template="repos_list"></ul>
108
      <div class="repo_query_bottom_buttons">
109
	<button data-template="cancel_but">Cancel</button>
110
      </div>
111
    </div>
112
    <div data-template="install_view_container" class="hide">
113
      <!-- Install view will be dynamically inserted here. -->
114
    </div>
115
  </div>
116

  
117
  <li id="repo_query_single_repo" data-template="main_li"
118
      class="repo_query_repo_li">
119
    <div class="top_line"></div>
120
    <div class="repo_query_entry">
121
      <div class="repo_query_entry_info">
122
	<label data-template="repo_url_label"></label>
123
      </div>
124
      <span class="repo_query_buttons">
125
	<button data-template="show_results_but">
126
	  Show results
127
	</button>
128
	<button data-template="hide_results_but" class="hide">
129
	  Hide results
130
	</button>
131
      </span>
132
    </div>
133
    <div data-template="list_container" class="hide repo_query_results_list">
134
      <span data-template="info_span">Querying repository...</span>
135
      <ul data-template="results_list" class="hide"></ul>
136
    </div>
137
  </li>
138

  
139
  <li id="repo_query_single_result" data-template="main_li"
140
      class="repo_query_entry repo_query_result_li">
141
    <div class="repo_query_entry_info">
142
      <span data-template="mapping_name"></span>
143
      <span data-template="mapping_id" class="repo_query_mapping_id"></span>
144
    </div>
145
    <span><button data-template="install_but">Install preview</button></span>
146
  </li>
147
</template>
148
#ENDIF
html/repo_query.js
1
/**
2
 * This file is part of Haketilo.
3
 *
4
 * Function: Show available repositories and allow querying them for resources.
5
 *
6
 * Copyright (C) 2022 Wojtek Kosior
7
 *
8
 * This program is free software: you can redistribute it and/or modify
9
 * it under the terms of the GNU General Public License as published by
10
 * the Free Software Foundation, either version 3 of the License, or
11
 * (at your option) any later version.
12
 *
13
 * This program is distributed in the hope that it will be useful,
14
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
15
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
16
 * GNU General Public License for more details.
17
 *
18
 * As additional permission under GNU GPL version 3 section 7, you
19
 * may distribute forms of that code without the copy of the GNU
20
 * GPL normally required by section 4, provided you include this
21
 * license notice and, in case of non-source distribution, a URL
22
 * through which recipients can access the Corresponding Source.
23
 * If you modify file(s) with this exception, you may extend this
24
 * exception to your version of the file(s), but you are not
25
 * obligated to do so. If you do not wish to do so, delete this
26
 * exception statement from your version.
27
 *
28
 * As a special exception to the GPL, any HTML file which merely
29
 * makes function calls to this code, and for that purpose
30
 * includes it by reference shall be deemed a separate work for
31
 * copyright law purposes. If you modify this code, you may extend
32
 * this exception to your version of the code, but you are not
33
 * obligated to do so. If you do not wish to do so, delete this
34
 * exception statement from your version.
35
 *
36
 * You should have received a copy of the GNU General Public License
37
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
38
 *
39
 * I, Wojtek Kosior, thereby promise not to sue for violation of this file's
40
 * license. Although I request that you do not make use of this code in a
41
 * proprietary program, I am not going to enforce this in court.
42
 */
43

  
44
#IMPORT common/indexeddb.js AS haketilodb
45

  
46
#FROM common/browser.js   IMPORT browser
47
#FROM html/DOM_helpers.js IMPORT clone_template, Showable
48
#FROM common/entities.js  IMPORT item_id_string, version_string, \
49
                                 is_valid_version
50
#FROM html/install.js     IMPORT InstallView
51

  
52
const coll = new Intl.Collator();
53

  
54
function ResultEntry(repo_entry, mapping_ref) {
55
    Object.assign(this, clone_template("repo_query_single_result"));
56
    Object.assign(this, {repo_entry, mapping_ref});
57

  
58
    this.mapping_name.innerText = mapping_ref.long_name;
59
    this.mapping_id.innerText = item_id_string(mapping_ref);
60

  
61
    const iv = repo_entry.query_view.install_view;
62

  
63
    function start_install() {
64
	iv.show(repo_entry.repo_url, "mapping",
65
		mapping_ref.identifier, mapping_ref.version);
66
    }
67

  
68
    const cb = repo_entry.query_view.install_view.when_hidden(start_install);
69
    this.install_but.addEventListener("click", cb);
70
}
71

  
72
function RepoEntry(query_view, repo_url) {
73
    Object.assign(this, clone_template("repo_query_single_repo"));
74
    Object.assign(this, {query_view, repo_url});
75
    this.results_shown_before = false;
76

  
77
    this.repo_url_label.innerText = repo_url;
78

  
79
    const query_results = async () => {
80
	const msg = [
81
	    "repo_query",
82
	    `${repo_url}query?url=${encodeURIComponent(query_view.url)}`
83
	];
84
	const response = await browser.tabs.sendMessage(query_view.tab_id, msg);
85

  
86
	if ("error" in response)
87
	    throw "Failure to communicate with repository :(";
88

  
89
	if (!response.ok)
90
	    throw `Repository sent HTTP code ${response.status} :(`;
91
	if ("error_json" in response)
92
	    throw "Repository's response is not valid JSON :(";
93

  
94
	if (!is_valid_version(response.json.api_schema_version)) {
95
	    var bad_api_ver = "";
96
	} else if (response.json.api_schema_version > [1]) {
97
	    var bad_api_ver =
98
		` (${version_string(response.json.api_schema_version)})`;
99
	} else {
100
	    var bad_api_ver = false;
101
	}
102

  
103
	if (bad_api_ver !== false)
104
	    throw `Results were served using unsupported Hydrilla API version${bad_api_ver}. You might need to update Haketilo.`;
105

  
106
	/* TODO: here we should perform JSON schema validation! */
107

  
108
	return response.json.mappings;
109
    }
110

  
111
    const populate_results = async () => {
112
	this.results_shown_before = true;
113

  
114
	try {
115
	    var results = await query_results();
116
	} catch(e) {
117
	    this.info_span.innerText = e;
118
	    return;
119
	}
120

  
121
	this.info_span.remove();
122
	this.results_list.classList.remove("hide");
123

  
124
	this.result_entries = results.map(ref => new ResultEntry(this, ref));
125

  
126
	const to_append = this.result_entries.length > 0 ?
127
	      this.result_entries.map(re => re.main_li) :
128
	      ["No results :("];
129

  
130
	this.results_list.append(...to_append);
131
    }
132

  
133
    let show_results = () => {
134
	if (!query_view.shown)
135
	    return;
136

  
137
	if (!this.results_shown_before)
138
	    populate_results();
139

  
140
	this.list_container.classList.remove("hide");
141
	this.hide_results_but.classList.remove("hide");
142
	this.show_results_but.classList.add("hide");
143
    }
144
    show_results = query_view.install_view.when_hidden(show_results);
145

  
146
    let hide_results = () => {
147
	if (!query_view.shown)
148
	    return;
149

  
150
	this.list_container.classList.add("hide");
151
	this.hide_results_but.classList.add("hide");
152
	this.show_results_but.classList.remove("hide");
153
    }
154
    hide_results = query_view.install_view.when_hidden(hide_results);
155

  
156
    this.show_results_but.addEventListener("click", show_results);
157
    this.hide_results_but.addEventListener("click", hide_results);
158
}
159

  
160
const container_ids = ["repos_list_container", "install_view_container"];
161

  
162
function RepoQueryView(tab_id, on_view_show, on_view_hide) {
163
    Showable.call(this, on_view_show, on_view_hide);
164

  
165
    Object.assign(this, clone_template("repo_query"));
166
    this.tab_id = tab_id;
167

  
168
    const show_container = name => {
169
	for (const cid of container_ids) {
170
	    if (cid !== name)
171
		this[cid].classList.add("hide");
172
	}
173
	this[name].classList.remove("hide");
174
    }
175

  
176
    this.install_view = new InstallView(
177
	tab_id,
178
	() => show_container("install_view_container"),
179
	() => show_container("repos_list_container")
180
    );
181
    this.install_view_container.prepend(this.install_view.main_div);
182

  
183
    const show_super = this.show;
184
    this.show = async url => {
185
	if (!show_super())
186
	    return;
187

  
188
	this.url = url;
189
	this.url_span.innerText = url;
190

  
191
	[...this.repos_list.children].forEach(c => c.remove());
192

  
193
	const repo_urls = await haketilodb.get_repos();
194
	repo_urls.sort((a, b) => coll.compare(a, b));
195
	this.repo_entries = repo_urls.map(ru => new RepoEntry(this, ru));
196

  
197
	if (repo_urls.length === 0) {
198
	    const info_li = document.createElement("li");
199
	    info_li.innerText = "You have no repositories configured :(";
200
	    this.repos_list.append(info_li);
201
	    return;
202
	}
203

  
204
	this.repos_list.append(...this.repo_entries.map(re => re.main_li));
205
    }
206

  
207
    this.cancel_but.addEventListener("click",
208
				     this.install_view.when_hidden(this.hide));
209
}
210
#EXPORT RepoQueryView
html/settings.html
115 115
#INCLUDE html/item_preview.html
116 116
#INCLUDE html/text_entry_list.html
117 117
#INCLUDE html/payload_create.html
118
    <ul id="tab_heads" class="has_bottom_line">
118
    <ul id="tab_heads">
119 119
      <li id="blocking_head"> Blocking </li>
120 120
      <li id="mappings_head"> Mappings </li>
121 121
      <li id="resources_head"> Resources </li>
122 122
      <li id="new_payload_head" class="active_head"> New payload </li>
123 123
      <li id="repos_head"> Repositories </li>
124 124
    </ul>
125
    <div id="top_menu_line" class="bottom_line"></div>
125
    <div id="top_menu_line" class="top_line"></div>
126 126
    <div id="blocking_tab" class="tab">
127 127
      <div id="blocking_editable_container" class="grid_2">
128 128
	<div id="blocking_list_container">
html/text_entry_list.js
162 162
	[this.cancel_but,       this.make_noneditable],
163 163
	[this.noneditable_view, this.make_editable],
164 164
    ])
165
	node.addEventListener("click", dialog.when_hidden(list.dialog_ctx, cb));
165
	node.addEventListener("click", list.dialog_ctx.when_hidden(cb));
166 166

  
167 167
    const enter_cb = e => (e.key === 'Enter') && enter_hit();
168 168
    this.input.addEventListener("keypress",
169
				dialog.when_hidden(list.dialog_ctx, enter_cb));
169
				list.dialog_ctx.when_hidden(enter_cb));
170 170

  
171 171
    if (entry_idx > 0) {
172 172
	const prev_text = list.shown_texts[entry_idx - 1];
......
227 227

  
228 228
    const add_new = () => new Entry(null, this, 0);
229 229

  
230
    this.new_but.addEventListener("click",
231
				  dialog.when_hidden(dialog_ctx, add_new));
230
    this.new_but.addEventListener("click", dialog_ctx.when_hidden(add_new));
232 231
}
233 232

  
234 233
async function repo_list(dialog_ctx) {
test/server.py
31 31
from pathlib import Path
32 32
from urllib.parse import parse_qs
33 33
from threading import Thread
34
import traceback
34 35

  
35 36
from .proxy_core import ProxyRequestHandler, ThreadingHTTPServer
36 37
from .misc_constants import *
......
84 85
                status_code, headers = 404, {'Content-Type': 'text/plain'}
85 86
                resp_body = b'Handler for this URL not found.'
86 87

  
87
        except Exception as e:
88
            status_code, headers, resp_body = 500, {'Content-Type': 'text/plain'}, b'Internal Error:\n' + repr(e).encode()
88
        except Exception:
89
            status_code = 500
90
            headers     = {'Content-Type': 'text/plain'}
91
            resp_body   = b'Internal Error:\n' + traceback.format_exc().encode()
89 92

  
90 93
        headers['Content-Length'] = str(len(resp_body))
91 94
        self.send_response(status_code)
test/unit/test_install.py
25 25
from ..script_loader import load_script
26 26
from .utils import *
27 27

  
28
def content_script():
29
    script = load_script('content/repo_query_cacher.js')
30
    return f'{script}; {tab_id_asker}; start();'
31

  
32
def background_script():
33
    script = load_script('background/broadcast_broker.js',
34
                         '#IMPORT background/CORS_bypass_server.js')
35
    return f'{script}; {tab_id_responder}; start(); CORS_bypass_server.start();'
36

  
37 28
def setup_view(driver, execute_in_page):
38
    tab_id = run_content_script_in_new_window(driver, 'https://gotmyowndoma.in')
29
    mock_cacher(execute_in_page)
39 30

  
40 31
    execute_in_page(load_script('html/install.js'))
41 32
    container_ids, containers_objects = execute_in_page(
42 33
        '''
43 34
        const cb_calls = [];
44
        const install_view = new InstallView(arguments[0],
35
        const install_view = new InstallView(0,
45 36
                                             () => cb_calls.push("show"),
46 37
                                             () => cb_calls.push("hide"));
47 38
        document.body.append(install_view.main_div);
48 39
        const ets = () => install_view.item_entries;
49 40
        const shw = slice => [cb_calls.slice(slice || 0), install_view.shown];
50 41
        returnval([container_ids, container_ids.map(cid => install_view[cid])]);
51
        ''',
52
        tab_id)
42
        ''')
53 43

  
54 44
    containers = dict(zip(container_ids, containers_objects))
55 45

  
......
60 50
    return containers, assert_container_displayed
61 51

  
62 52
install_ext_data = {
63
    'content_script': content_script,
64
    'background_script': background_script,
53
    'background_script': broker_js,
65 54
    'extra_html': ExtraHTML('html/install.html', {}),
66 55
    'navigate_to': 'html/install.html'
67 56
}
......
148 137

  
149 138
    execute_in_page('returnval(install_view.install_but);').click()
150 139
    installed = lambda d: 'ly installed!' in containers['dialog_container'].text
151
    WebDriverWait(driver, 10000).until(installed)
140
    WebDriverWait(driver, 10).until(installed)
152 141

  
153 142
    assert execute_in_page('returnval(shw(2));') == [['show'], True]
154 143
    execute_in_page('returnval(install_view.dialog_ctx.ok_but);').click()
......
188 177
    # All items are up to date - verify dialog is instead shown in this case.
189 178
    execute_in_page('install_view.show(...arguments);',
190 179
                    'https://hydril.la/', 'mapping', root_mapping_id)
191
    assert execute_in_page('returnval(shw(6));') == [['show'], True]
192
    assert_container_displayed('dialog_container')
180

  
181
    fetched = lambda d: 'Fetching ' not in containers['dialog_container'].text
182
    WebDriverWait(driver, 10).until(fetched)
183

  
193 184
    assert 'Nothing to do - packages already installed.' \
194 185
        in containers['dialog_container'].text
186
    assert_container_displayed('dialog_container')
187

  
188
    assert execute_in_page('returnval(shw(6));') == [['show'], True]
195 189
    execute_in_page('returnval(install_view.dialog_ctx.ok_but);').click()
196 190
    assert execute_in_page('returnval(shw(6));') == [['show', 'hide'], False]
197 191

  
test/unit/test_repo_query.py
1
# SPDX-License-Identifier: CC0-1.0
2

  
3
"""
4
Haketilo unit tests - .............
5
"""
6

  
7
# This file is part of Haketilo
8
#
9
# Copyright (C) 2022 Wojtek Kosior <koszko@koszko.org>
10
#
11
# This program is free software: you can redistribute it and/or modify
12
# it under the terms of the CC0 1.0 Universal License as published by
13
# the Creative Commons Corporation.
14
#
15
# This program is distributed in the hope that it will be useful,
16
# but WITHOUT ANY WARRANTY; without even the implied warranty of
17
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
18
# CC0 1.0 Universal License for more details.
19

  
20
import pytest
21
import json
22
from selenium.webdriver.support.ui import WebDriverWait
23

  
24
from ..extension_crafting import ExtraHTML
25
from ..script_loader import load_script
26

  
27
from ..extension_crafting import ExtraHTML
28
from ..script_loader import load_script
29
from .utils import *
30

  
31
repo_urls = [f'https://hydril.la/{s}' for s in ('', '1/', '2/', '3/', '4/')]
32

  
33
queried_url = 'https://example_a.com/something'
34

  
35
def setup_view(execute_in_page, repo_urls):
36
    mock_cacher(execute_in_page)
37

  
38
    execute_in_page(load_script('html/repo_query.js'))
39
    execute_in_page(
40
        '''
41
        const repo_proms = arguments[0].map(url => haketilodb.set_repo(url));
42

  
43
        const cb_calls = [];
44
        const view = new RepoQueryView(0,
45
                                       () => cb_calls.push("show"),
46
                                       () => cb_calls.push("hide"));
47
        document.body.append(view.main_div);
48
        const shw = slice => [cb_calls.slice(slice || 0), view.shown];
49

  
50
        returnval(Promise.all(repo_proms));
51
        ''',
52
        repo_urls)
53

  
54
repo_query_ext_data = {
55
    'background_script': broker_js,
56
    'extra_html': ExtraHTML('html/repo_query.html', {}),
57
    'navigate_to': 'html/repo_query.html'
58
}
59

  
60
@pytest.mark.ext_data(repo_query_ext_data)
61
@pytest.mark.usefixtures('webextension')
62
def test_repo_query_normal_usage(driver, execute_in_page):
63
    """
64
    Test of using the repo query view to browse results from repository and to
65
    start installation.
66
    """
67
    setup_view(execute_in_page, repo_urls)
68

  
69
    assert execute_in_page('returnval(shw());') == [[], False]
70

  
71
    execute_in_page('view.show(arguments[0]);', queried_url)
72

  
73
    assert execute_in_page('returnval(shw());') == [['show'], True]
74

  
75
    def get_repo_entries(driver):
76
        return execute_in_page(
77
            f'returnval((view.repo_entries || []).map({nodes_props_code}));'
78
        )
79

  
80
    repo_entries = WebDriverWait(driver, 10).until(get_repo_entries)
81

  
82
    assert len(repo_urls) == len(repo_entries)
83

  
84
    for url, entry in reversed(list(zip(repo_urls, repo_entries))):
85
        assert url in entry['main_li'].text
86

  
87
    but_ids = ('show_results_but', 'hide_results_but')
88
    for but_idx in (0, 1, 0):
89
        assert bool(but_idx) == entry['list_container'].is_displayed()
90

  
91
        assert not entry[but_ids[1 - but_idx]].is_displayed()
92

  
93
        entry[but_ids[but_idx]].click()
94

  
95
    def get_mapping_entries(driver):
96
        return execute_in_page(
97
            f'''{{
98
            const result_entries = (view.repo_entries[0].result_entries || []);
99
            returnval(result_entries.map({nodes_props_code}));
100
            }}''')
101

  
102
    mapping_entries = WebDriverWait(driver, 10).until(get_mapping_entries)
103

  
104
    assert len(mapping_entries) == 3
105

  
106
    expected_names = ['MAPPING_ABCD', 'MAPPING_ABCD-DEFG-GHIJ', 'MAPPING_A']
107

  
108
    for name, entry in zip(expected_names, mapping_entries):
109
        assert entry['mapping_name'].text == name
110
        assert entry['mapping_id'].text == f'{name.lower()}-2022.5.11'
111

  
112
    containers = execute_in_page(
113
        '''{
114
        const reductor = (acc, k) => Object.assign(acc, {[k]: view[k]});
115
        returnval(container_ids.reduce(reductor, {}));
116
        }''')
117

  
118
    for id, container in containers.items():
119
        assert (id == 'repos_list_container') == container.is_displayed()
120

  
121
    entry['install_but'].click()
122

  
123
    for id, container in containers.items():
124
        assert (id == 'install_view_container') == container.is_displayed()
125

  
126
    execute_in_page('returnval(view.install_view.cancel_but);').click()
127

  
128
    for id, container in containers.items():
129
        assert (id == 'repos_list_container') == container.is_displayed()
130

  
131
    assert execute_in_page('returnval(shw());') == [['show'], True]
132
    execute_in_page('returnval(view.cancel_but);').click()
133
    assert execute_in_page('returnval(shw());') == [['show', 'hide'], False]
134

  
135
@pytest.mark.ext_data(repo_query_ext_data)
136
@pytest.mark.usefixtures('webextension')
137
@pytest.mark.parametrize('message', [
138
    'browsing_for',
139
    'no_repos',
140
    'failure_to_communicate',
141
    'HTTP_code',
142
    'invalid_JSON',
143
    'newer_API_version',
144
    'invalid_API_version',
145
    'querying_repo',
146
    'no_results'
147
])
148
def test_repo_query_messages(driver, execute_in_page, message):
149
    """
150
    Test of loading and error messages shown in parts of the repo query view.
151
    """
152
    def has_msg(message, elem=None):
153
        def has_msg_and_is_visible(dummy_driver):
154
            if elem:
155
                return elem.is_displayed() and message in elem.text
156
            else:
157
                return message in driver.page_source
158
        return has_msg_and_is_visible
159

  
160
    def show_and_wait_for_repo_entry():
161
        execute_in_page('view.show(arguments[0]);', queried_url)
162
        done = lambda d: execute_in_page('returnval(!!view.repo_entries);')
163
        WebDriverWait(driver, 10).until(done)
164
        execute_in_page(
165
            '''
166
            if (view.repo_entries.length > 0)
167
                view.repo_entries[0].show_results_but.click();
168
            ''')
169

  
170
    if message == 'browsing_for':
171
        setup_view(execute_in_page, [])
172
        show_and_wait_for_repo_entry()
173

  
174
        elem = execute_in_page('returnval(view.url_span.parentNode);')
175
        assert has_msg(f'Browsing custom resources for {queried_url}.', elem)(0)
176
    elif message == 'no_repos':
177
        setup_view(execute_in_page, [])
178
        show_and_wait_for_repo_entry()
179

  
180
        elem = execute_in_page('returnval(view.repos_list);')
181
        done = has_msg('You have no repositories configured :(', elem)
182
        WebDriverWait(driver, 10).until(done)
183
    elif message == 'failure_to_communicate':
184
        setup_view(execute_in_page, repo_urls)
185
        execute_in_page(
186
            'browser.tabs.sendMessage = () => Promise.resolve({error: "sth"});'
187
        )
188
        show_and_wait_for_repo_entry()
189

  
190
        elem = execute_in_page('returnval(view.repo_entries[0].info_span);')
191
        done = has_msg('Failure to communicate with repository :(', elem)
192
        WebDriverWait(driver, 10).until(done)
193
    elif message == 'HTTP_code':
194
        setup_view(execute_in_page, repo_urls)
195
        execute_in_page(
196
            '''
197
            const response = {ok: false, status: 405};
198
            browser.tabs.sendMessage = () => Promise.resolve(response);
199
            ''')
200
        show_and_wait_for_repo_entry()
201

  
202
        elem = execute_in_page('returnval(view.repo_entries[0].info_span);')
203
        done = has_msg('Repository sent HTTP code 405 :(', elem)
204
        WebDriverWait(driver, 10).until(done)
205
    elif message == 'invalid_JSON':
206
        setup_view(execute_in_page, repo_urls)
207
        execute_in_page(
208
            '''
209
            const response = {ok: true, status: 200, error_json: "sth"};
210
            browser.tabs.sendMessage = () => Promise.resolve(response);
211
            ''')
212
        show_and_wait_for_repo_entry()
213

  
214
        elem = execute_in_page('returnval(view.repo_entries[0].info_span);')
215
        done = has_msg("Repository's response is not valid JSON :(", elem)
216
        WebDriverWait(driver, 10).until(done)
217
    elif message == 'newer_API_version':
218
        setup_view(execute_in_page, repo_urls)
219
        execute_in_page(
220
            '''
221
            const response = {
222
                ok: true,
223
                status: 200,
224
                json: {api_schema_version: [1234]}
225
            };
226
            browser.tabs.sendMessage = () => Promise.resolve(response);
227
            ''')
228
        show_and_wait_for_repo_entry()
229

  
230
        elem = execute_in_page('returnval(view.repo_entries[0].info_span);')
231
        msg = 'Results were served using unsupported Hydrilla API version (1234). You might need to update Haketilo.'
232
        WebDriverWait(driver, 10).until(has_msg(msg, elem))
233
    elif message == 'invalid_API_version':
234
        setup_view(execute_in_page, repo_urls)
235
        execute_in_page(
236
            '''
237
            const response = {
238
                ok: true,
239
                status: 200,
240
                json: {api_schema_version: null}
241
            };
242
            browser.tabs.sendMessage = () => Promise.resolve(response);
243
            ''')
244
        show_and_wait_for_repo_entry()
245

  
246
        elem = execute_in_page('returnval(view.repo_entries[0].info_span);')
247
        msg = 'Results were served using unsupported Hydrilla API version. You might need to update Haketilo.'
248
        WebDriverWait(driver, 10).until(has_msg(msg, elem))
249
    elif message == 'querying_repo':
250
        setup_view(execute_in_page, repo_urls)
251
        execute_in_page(
252
            'browser.tabs.sendMessage = () => new Promise(() => {});'
253
        )
254
        show_and_wait_for_repo_entry()
255

  
256
        elem = execute_in_page('returnval(view.repo_entries[0].info_span);')
257
        assert has_msg('Querying repository...', elem)(0)
258
    elif message == 'no_results':
259
        setup_view(execute_in_page, repo_urls)
260
        execute_in_page(
261
            '''
262
            const response = {
263
                ok: true,
264
                status: 200,
265
                json: {
266
                    api_schema_version: [1],
267
                    api_schema_revision: 1,
268
                    mappings: []
269
                }
270
            };
271
            browser.tabs.sendMessage = () => Promise.resolve(response);
272
            ''')
273
        show_and_wait_for_repo_entry()
274

  
275
        elem = execute_in_page('returnval(view.repo_entries[0].results_list);')
276
        WebDriverWait(driver, 10).until(has_msg('No results :(', elem))
277
    else:
278
        raise Exception('made a typo in test function params?')
test/unit/test_repo_query_cacher.py
22 22
from selenium.webdriver.support.ui import WebDriverWait
23 23

  
24 24
from ..script_loader import load_script
25
from .utils import *
26 25

  
27 26
def content_script():
28 27
    script = load_script('content/repo_query_cacher.js')
......
39 38
        ''',
40 39
        tab_id, url)
41 40

  
41
"""
42
tab_id_responder is meant to be appended to background script of a test
43
extension.
44
"""
45
tab_id_responder = '''
46
function tell_tab_id(msg, sender, respond_cb) {
47
    if (msg[0] === "learn_tab_id")
48
        respond_cb(sender.tab.id);
49
}
50
browser.runtime.onMessage.addListener(tell_tab_id);
51
'''
52

  
53
"""
54
tab_id_asker is meant to be appended to content script of a test extension.
55
"""
56
tab_id_asker = '''
57
browser.runtime.sendMessage(["learn_tab_id"])
58
    .then(tid => window.wrappedJSObject.haketilo_tab = tid);
59
'''
60

  
61
def run_content_script_in_new_window(driver, url):
62
    """
63
    Expect an extension to be loaded which had tab_id_responder and tab_id_asker
64
    appended to its background and content scripts, respectively.
65
    Open the provided url in a new tab, find its tab id and return it, with
66
    current window changed back to the initial one.
67
    """
68
    initial_handle = driver.current_window_handle
69
    handles = driver.window_handles
70
    driver.execute_script('window.open(arguments[0], "_blank");', url)
71
    WebDriverWait(driver, 10).until(lambda d: d.window_handles is not handles)
72
    new_handle = [h for h in driver.window_handles if h not in handles][0]
73

  
74
    driver.switch_to.window(new_handle)
75

  
76
    get_tab_id = lambda d: d.execute_script('return window.haketilo_tab;')
77
    tab_id = WebDriverWait(driver, 10).until(get_tab_id)
78

  
79
    driver.switch_to.window(initial_handle)
80
    return tab_id
81

  
42 82
@pytest.mark.ext_data({
43 83
    'content_script': content_script,
44 84
    'background_script': lambda: bypass_js() + ';' + tab_id_responder
test/unit/utils.py
202 202
            ''',
203 203
            nonce)
204 204

  
205
"""
206
tab_id_responder is meant to be appended to background script of a test
207
extension.
208
"""
209
tab_id_responder = '''
210
function tell_tab_id(msg, sender, respond_cb) {
211
    if (msg[0] === "learn_tab_id")
212
        respond_cb(sender.tab.id);
213
}
214
browser.runtime.onMessage.addListener(tell_tab_id);
215
'''
216

  
217
"""
218
tab_id_asker is meant to be appended to content script of a test extension.
219
"""
220
tab_id_asker = '''
221
browser.runtime.sendMessage(["learn_tab_id"])
222
    .then(tid => window.wrappedJSObject.haketilo_tab = tid);
223
'''
224

  
225
def run_content_script_in_new_window(driver, url):
205
def mock_cacher(execute_in_page):
226 206
    """
227
    Expect an extension to be loaded which had tab_id_responder and tab_id_asker
228
    appended to its background and content scripts, respectively.
229
    Open the provided url in a new tab, find its tab id and return it, with
230
    current window changed back to the initial one.
207
    Some parts of code depend on content/repo_query_cacher.js and
208
    background/CORS_bypass_server.js running in their appropriate contexts. This
209
    function modifies the relevant browser.runtime.sendMessage function to
210
    perform fetch(), bypassing the cacher.
231 211
    """
232
    initial_handle = driver.current_window_handle
233
    handles = driver.window_handles
234
    driver.execute_script('window.open(arguments[0], "_blank");', url)
235
    WebDriverWait(driver, 10).until(lambda d: d.window_handles is not handles)
236
    new_handle = [h for h in driver.window_handles if h not in handles][0]
237

  
238
    driver.switch_to.window(new_handle)
212
    execute_in_page(
213
        '''{
214
        const old_sendMessage = browser.tabs.sendMessage, old_fetch = fetch;
215
        async function new_sendMessage(tab_id, msg) {
216
            if (msg[0] !== "repo_query")
217
                return old_sendMessage(tab_id, msg);
218

  
219
            /* Use snapshotted fetch(), allow other test code to override it. */
220
            const response = await old_fetch(msg[1]);
221
            if (!response)
222
                return {error: "Something happened :o"};
223

  
224
            const result = {ok: response.ok, status: response.status};
225
            try {
226
                result.json = await response.json();
227
            } catch(e) {
228
                result.error_json = "" + e;
229
            }
230
            return result;
231
        }
239 232

  
240
    get_tab_id = lambda d: d.execute_script('return window.haketilo_tab;')
241
    tab_id = WebDriverWait(driver, 10).until(get_tab_id)
233
        browser.tabs.sendMessage = new_sendMessage;
234
        }''')
242 235

  
243
    driver.switch_to.window(initial_handle)
244
    return tab_id
236
"""
237
Convenience snippet of code to retrieve a copy of given object with only those
238
properties present which are DOM nodes. This makes it possible to easily access
239
DOM nodes stored in a javascript object that also happens to contain some
240
other properties that make it impossible to return from a Selenium script.
241
"""
242
nodes_props_code = '''\
243
(obj => {
244
    const result = {};
245
    for (const [key, value] of Object.entries(obj)) {
246
        if (value instanceof Node)
247
            result[key] = value;
248
    }
249
    return result;
250
})'''
test/world_wide_library.py
107 107

  
108 108
# Mock a Hydrilla repository.
109 109

  
110
make_handler = lambda txt: lambda c, g, p: (200, {}, txt)
111

  
110 112
# Mock files in the repository.
111 113
sample_contents = [f'Mi povas manĝi vitron, ĝi ne damaĝas min {i}'
112 114
                   for i in range(9)]
113
sample_hashes   = [sha256(c.encode()).digest().hex() for c in sample_contents]
115
sample_hashes = [sha256(c.encode()).digest().hex() for c in sample_contents]
114 116

  
115
file_url     = lambda hashed:   f'https://hydril.la/file/sha256-{hashed}'
116
file_handler = lambda contents: lambda c, g, p: (200, {}, contents)
117
file_url = lambda hashed: f'https://hydril.la/file/sha256-{hashed}'
117 118

  
118
sample_files_catalog = dict([(file_url(h), file_handler(c))
119
sample_files_catalog = dict([(file_url(h), make_handler(c))
119 120
                             for h, c in zip(sample_hashes, sample_contents)])
120 121

  
121 122
# Mock resources and mappings in the repository.
......
145 146

  
146 147
sample_resources_catalog = {}
147 148
sample_mappings_catalog = {}
149
sample_queries = {}
148 150

  
149 151
for srt in sample_resource_templates:
150 152
    resource = make_sample_resource()
......
160 162
        file_ref = {'file': f'file_{i}', 'sha256': sample_hashes[i]}
161 163
        resource[('source_copyright', 'scripts')[i & 1]].append(file_ref)
162 164

  
163
    # Keeping it simple - just make one corresponding mapping for each resource.
164
    payloads = {'https://example.com/*': {'identifier': resource['identifier']}}
165
    resource_versions = [resource['version'], resource['version'].copy()]
166
    resource_versions[1][-1] += 1
165 167

  
166 168
    mapping = make_sample_mapping()
167 169
    mapping['api_schema_version']  = [1]
......
170 172
    mapping['long_name']           = mapping['identifier'].upper()
171 173
    mapping['uuid']                = str(uuid4())
172 174
    mapping['source_copyright']    = resource['source_copyright']
173
    mapping['payloads']            = payloads
174 175

  
175
    make_handler = lambda txt: lambda c, g, p: (200, {}, txt)
176
    mapping_versions = [mapping['version'], mapping['version'].copy()]
177
    mapping_versions[1][-1] += 1
178

  
179
    sufs = [srt["id_suffix"], *[l for l in srt["id_suffix"] if l.isalpha()]]
180
    patterns = [f'https://example_{suf}.com/*' for suf in set(sufs)]
181
    payloads = {}
182

  
183
    for pat in patterns:
184
        payloads[pat] = {'identifier': resource['identifier']}
185

  
186
        queryable_url = pat.replace('*', 'something')
187
        if queryable_url not in sample_queries:
188
            sample_queries[queryable_url] = []
189

  
190
        sample_queries[queryable_url].append({
191
            'identifier': mapping['identifier'],
192
            'long_name':  mapping['long_name'],
193
            'version':    mapping_versions[1]
194
        })
176 195

  
177
    for item, catalog in [
178
        (resource, sample_resources_catalog),
179
        (mapping,  sample_mappings_catalog)
196
    mapping['payloads'] = payloads
197

  
198
    for item, versions, catalog in [
199
        (resource, resource_versions, sample_resources_catalog),
200
        (mapping,  mapping_versions,  sample_mappings_catalog)
180 201
    ]:
181 202
        fmt = f'https://hydril.la/{item["type"]}/{item["identifier"]}%s.json'
182 203
        # Make 2 versions of each item so that we can test updates.
183
        for i in range(2):
204
        for ver in versions:
205
            item['version'] = ver
184 206
            for fmt_arg in ('', '/' + item_version_string(item)):
185 207
                catalog[fmt % fmt_arg] = make_handler(json.dumps(item))
186
            item['version'][-1] += 1
208

  
209
def serve_query(command, get_params, post_params):
210
    response = {
211
        'api_schema_version':  [1],
212
        'api_schema_revision': 1,
213
        'mappings':            sample_queries[get_params['url'][0]]
214
    }
215

  
216
    return (200, {}, json.dumps(response))
217

  
218
sample_queries_catalog = dict([(f'https://hydril.la/{suf}query', serve_query)
219
                               for suf in ('', '1/', '2/', '3/', '4/')])
187 220

  
188 221
catalog = {
189 222
    'http://gotmyowndoma.in':
......
233 266

  
234 267
    **sample_files_catalog,
235 268
    **sample_resources_catalog,
236
    **sample_mappings_catalog
269
    **sample_mappings_catalog,
270
    **sample_queries_catalog
237 271
}

Also available in: Unified diff