Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(130)

Side by Side Diff: pkg/scheduled_test/lib/src/schedule.dart

Issue 12251012: Add built-in timeouts to scheduled_test. (Closed) Base URL: https://dart.googlecode.com/svn/branches/bleeding_edge/dart
Patch Set: Created 7 years, 10 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch | Annotate | Revision Log
OLDNEW
1 // Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file 1 // Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file
2 // for details. All rights reserved. Use of this source code is governed by a 2 // for details. All rights reserved. Use of this source code is governed by a
3 // BSD-style license that can be found in the LICENSE file. 3 // BSD-style license that can be found in the LICENSE file.
4 4
5 library schedule; 5 library schedule;
6 6
7 import 'dart:async'; 7 import 'dart:async';
8 import 'dart:collection'; 8 import 'dart:collection';
9 9
10 import 'package:unittest/unittest.dart' as unittest; 10 import 'package:unittest/unittest.dart' as unittest;
11 11
12 import 'mock_clock.dart' as mock_clock;
12 import 'schedule_error.dart'; 13 import 'schedule_error.dart';
14 import 'substitute_future.dart';
13 import 'task.dart'; 15 import 'task.dart';
14 16
15 /// The schedule of tasks to run for a single test. This has three separate task 17 /// The schedule of tasks to run for a single test. This has three separate task
16 /// queues: [tasks], [onComplete], and [onException]. It also provides 18 /// queues: [tasks], [onComplete], and [onException]. It also provides
17 /// visibility into the current state of the schedule. 19 /// visibility into the current state of the schedule.
18 class Schedule { 20 class Schedule {
19 /// The main task queue for the schedule. These tasks are run before the other 21 /// The main task queue for the schedule. These tasks are run before the other
20 /// queues and generally constitute the main test body. 22 /// queues and generally constitute the main test body.
21 TaskQueue get tasks => _tasks; 23 TaskQueue get tasks => _tasks;
22 TaskQueue _tasks; 24 TaskQueue _tasks;
(...skipping 23 matching lines...) Expand all
46 /// skipped. 48 /// skipped.
47 TaskQueue get onComplete => _onComplete; 49 TaskQueue get onComplete => _onComplete;
48 TaskQueue _onComplete; 50 TaskQueue _onComplete;
49 51
50 /// Returns the [Task] that's currently executing, or `null` if there is no 52 /// Returns the [Task] that's currently executing, or `null` if there is no
51 /// such task. This will be `null` both before the schedule starts running and 53 /// such task. This will be `null` both before the schedule starts running and
52 /// after it's finished. 54 /// after it's finished.
53 Task get currentTask => _currentTask; 55 Task get currentTask => _currentTask;
54 Task _currentTask; 56 Task _currentTask;
55 57
56 /// Whether the schedule has finished running. This is only set once 58 /// The current state of the schedule.
57 /// [onComplete] has finished running. It will be set whether or not an 59 ScheduleState get state => _state;
58 /// exception has occurred. 60 ScheduleState _state = ScheduleState.SET_UP;
59 bool get done => _done;
60 bool _done = false;
61 61
62 // TODO(nweiz): make this a read-only view once issue 8321 is fixed. 62 // TODO(nweiz): make this a read-only view once issue 8321 is fixed.
63 63
64 /// Errors thrown by the task queues. 64 /// Errors thrown by the task queues.
65 /// 65 ///
66 /// When running tasks in [tasks], this will always be empty. If an error 66 /// When running tasks in [tasks], this will always be empty. If an error
67 /// occurs in [tasks], it will be added to this list and then [onException] 67 /// occurs in [tasks], it will be added to this list and then [onException]
68 /// will be run. If an error occurs there as well, it will be added to this 68 /// will be run. If an error occurs there as well, it will be added to this
69 /// list and [onComplete] will be run. Errors thrown during [onComplete] will 69 /// list and [onComplete] will be run. Errors thrown during [onComplete] will
70 /// also be added to this list, although no scheduled tasks will be run 70 /// also be added to this list, although no scheduled tasks will be run
71 /// afterwards. 71 /// afterwards.
72 /// 72 ///
73 /// Any out-of-band callbacks that throw errors will also have those errors 73 /// Any out-of-band callbacks that throw errors will also have those errors
74 /// added to this list. 74 /// added to this list.
75 final errors = <ScheduleError>[]; 75 final errors = <ScheduleError>[];
76 76
77 /// The task queue that's currently being run, or `null` if there is no such 77 /// The task queue that's currently being run. One of [tasks], [onException],
78 /// queue. One of [tasks], [onException], or [onComplete]. This will be `null` 78 /// or [onComplete]. This starts as [tasks], and can only be `null` after the
79 /// before the schedule starts running. 79 /// schedule is done.
80 TaskQueue get currentQueue => _done ? null : _currentQueue; 80 TaskQueue get currentQueue =>
81 _state == ScheduleState.DONE ? null : _currentQueue;
81 TaskQueue _currentQueue; 82 TaskQueue _currentQueue;
82 83
84 /// The time, in milliseconds, to wait before terminating a task queue for
85 /// inactivity. Defaults to 30 seconds. This can be set to `null` to disable
86 /// timeouts entirely.
87 ///
88 /// If a task queue times out, an error will be raised that can be handled as
89 /// usual in the [onException] and [onComplete] queues. If [onException] times
90 /// out, that can only be handled in [onComplete]; if [onComplete] times out,
91 /// that cannot be handled.
92 ///
93 /// If a task times out and then later completes with an error, that error
94 /// cannot be handled. The user will still be notified of it.
95 int get timeoutMs => _timeoutMs;
96 int _timeoutMs = 30 * 1000;
97 set timeoutMs(int value) {
98 _timeoutMs = value;
99 heartbeat();
100 }
101
83 /// The number of out-of-band callbacks that have been registered with 102 /// The number of out-of-band callbacks that have been registered with
84 /// [wrapAsync] but have yet to be called. 103 /// [wrapAsync] but have yet to be called.
85 int _pendingCallbacks = 0; 104 int _pendingCallbacks = 0;
86 105
87 /// A completer that will be completed once [_pendingCallbacks] reaches zero. 106 /// A completer that will be completed once [_pendingCallbacks] reaches zero.
88 /// This will only be non-`null` if [_awaitPendingCallbacks] has been called 107 /// This will only be non-`null` if [_awaitPendingCallbacks] has been called
89 /// while [_pendingCallbacks] is non-zero. 108 /// while [_pendingCallbacks] is non-zero.
90 Completer _noPendingCallbacks; 109 Completer _noPendingCallbacks;
91 110
111 /// The timer for keeping track of task timeouts. This may be null.
112 Timer _timeoutTimer;
113
92 /// Creates a new schedule with empty task queues. 114 /// Creates a new schedule with empty task queues.
93 Schedule() { 115 Schedule() {
94 _tasks = new TaskQueue._("tasks", this); 116 _tasks = new TaskQueue._("tasks", this);
95 _onComplete = new TaskQueue._("onComplete", this); 117 _onComplete = new TaskQueue._("onComplete", this);
96 _onException = new TaskQueue._("onException", this); 118 _onException = new TaskQueue._("onException", this);
119 _currentQueue = _tasks;
120
121 heartbeat();
97 } 122 }
98 123
99 /// Sets up this schedule by running [setUp], then runs all the task queues in 124 /// Sets up this schedule by running [setUp], then runs all the task queues in
100 /// order. Any errors in [setUp] will cause [onException] to run. 125 /// order. Any errors in [setUp] will cause [onException] to run.
101 Future run(void setUp()) { 126 Future run(void setUp()) {
102 return new Future.immediate(null).then((_) { 127 return new Future.immediate(null).then((_) {
103 try { 128 try {
104 setUp(); 129 setUp();
105 } catch (e, stackTrace) { 130 } catch (e, stackTrace) {
106 throw new ScheduleError.from(this, e, stackTrace: stackTrace); 131 throw new ScheduleError.from(this, e, stackTrace: stackTrace);
107 } 132 }
108 133
134 _state = ScheduleState.RUNNING;
109 return tasks._run(); 135 return tasks._run();
110 }).catchError((e) { 136 }).catchError((e) {
111 errors.add(e); 137 errors.add(e);
112 return onException._run().catchError((innerError) { 138 return onException._run().catchError((innerError) {
113 // If an error occurs in a task in the onException queue, make sure it's 139 // If an error occurs in a task in the onException queue, make sure it's
114 // registered in the error list and re-throw it. We could also re-throw 140 // registered in the error list and re-throw it. We could also re-throw
115 // `e`; ultimately, all the errors will be shown to the user if any 141 // `e`; ultimately, all the errors will be shown to the user if any
116 // ScheduleError is thrown. 142 // ScheduleError is thrown.
117 errors.add(innerError); 143 errors.add(innerError);
118 throw innerError; 144 throw innerError;
119 }).then((_) { 145 }).then((_) {
120 // If there are no errors in the onException queue, re-throw the 146 // If there are no errors in the onException queue, re-throw the
121 // original error that caused it to run. 147 // original error that caused it to run.
122 throw e; 148 throw e;
123 }); 149 });
124 }).whenComplete(() { 150 }).whenComplete(() {
125 return onComplete._run().catchError((e) { 151 return onComplete._run().catchError((e) {
126 // If an error occurs in a task in the onComplete queue, make sure it's 152 // If an error occurs in a task in the onComplete queue, make sure it's
127 // registered in the error list and re-throw it. 153 // registered in the error list and re-throw it.
128 errors.add(e); 154 errors.add(e);
129 throw e; 155 throw e;
130 }); 156 });
131 }).whenComplete(() { 157 }).whenComplete(() {
132 _done = true; 158 if (_timeoutTimer != null) _timeoutTimer.cancel();
159 _state = ScheduleState.DONE;
133 }); 160 });
134 } 161 }
135 162
136 /// Signals that an out-of-band error has occurred. Using [wrapAsync] along 163 /// Signals that an out-of-band error has occurred. Using [wrapAsync] along
137 /// with `throw` is usually preferable to calling this directly. 164 /// with `throw` is usually preferable to calling this directly.
138 /// 165 ///
139 /// The metadata in [AsyncError]s and [ScheduleError]s will be preserved. 166 /// The metadata in [AsyncError]s and [ScheduleError]s will be preserved.
140 void signalError(error, [stackTrace]) { 167 void signalError(error, [stackTrace]) {
168 heartbeat();
169
141 var scheduleError = new ScheduleError.from(this, error, 170 var scheduleError = new ScheduleError.from(this, error,
142 stackTrace: stackTrace, task: currentTask); 171 stackTrace: stackTrace);
143 if (_done) { 172 if (_state == ScheduleState.DONE) {
144 errors.add(scheduleError);
145 throw new StateError( 173 throw new StateError(
146 "An out-of-band error was signaled outside of wrapAsync after the " 174 "An out-of-band error was signaled outside of wrapAsync after the "
147 "schedule finished running.\n" 175 "schedule finished running.\n"
148 "${errorString()}"); 176 "${errorString()}");
149 } else if (currentQueue == null) { 177 } else if (state == ScheduleState.SET_UP) {
150 // If we're not done but there's no current queue, that means we haven't 178 // If we're setting up, throwing the error will pipe it into the main
151 // started yet and thus we're in setUp or the synchronous body of the
152 // function. Throwing the error will thus pipe it into the main
153 // error-handling code. 179 // error-handling code.
154 throw scheduleError; 180 throw scheduleError;
155 } else { 181 } else {
156 _currentQueue._signalError(scheduleError); 182 _currentQueue._signalError(scheduleError);
157 } 183 }
158 } 184 }
159 185
186 /// Notifies the schedule of an error that occurred in a task or out-of-band
187 /// callback after the appropriate queue has timed out. If this schedule is
188 /// still running, the error will be added to the errors list to be shown
189 /// along with the timeout error; otherwise, a top-level error will be thrown.
190 void _signalPostTimeoutError(error, [stackTrace]) {
191 var scheduleError = new ScheduleError.from(this, error,
192 stackTrace: stackTrace);
193 errors.add(scheduleError);
194 if (_state == ScheduleState.DONE) {
195 throw new StateError(
196 "An out-of-band error was caught after the test timed out.\n"
197 "${errorString()}");
198 }
199 }
200
160 /// Returns a function wrapping [fn] that pipes any errors into the schedule 201 /// Returns a function wrapping [fn] that pipes any errors into the schedule
161 /// chain. This will also block the current task queue from completing until 202 /// chain. This will also block the current task queue from completing until
162 /// the returned function has been called. It's used to ensure that 203 /// the returned function has been called. It's used to ensure that
163 /// out-of-band callbacks are properly handled by the scheduled test. 204 /// out-of-band callbacks are properly handled by the scheduled test.
164 /// 205 ///
165 /// The top-level `wrapAsync` function should usually be used in preference to 206 /// The top-level `wrapAsync` function should usually be used in preference to
166 /// this. 207 /// this.
167 Function wrapAsync(fn(arg)) { 208 Function wrapAsync(fn(arg)) {
168 if (_done) { 209 if (_state == ScheduleState.DONE) {
169 throw new StateError("wrapAsync called after the schedule has finished " 210 throw new StateError("wrapAsync called after the schedule has finished "
170 "running."); 211 "running.");
171 } 212 }
213 heartbeat();
214
215 var queue = currentQueue;
216 // It's possible that the queue timed out before this.
217 bool _timedOut() => queue != currentQueue || _pendingCallbacks == 0;
172 218
173 _pendingCallbacks++; 219 _pendingCallbacks++;
174 return (arg) { 220 return (arg) {
175 try { 221 try {
176 return fn(arg); 222 return fn(arg);
177 } catch (e, stackTrace) { 223 } catch (e, stackTrace) {
178 signalError(e, stackTrace); 224 if (_timedOut()) {
225 _signalPostTimeoutError(e, stackTrace);
226 } else {
227 signalError(e, stackTrace);
228 }
179 } finally { 229 } finally {
230 if (_timedOut()) return;
231
180 _pendingCallbacks--; 232 _pendingCallbacks--;
181 if (_pendingCallbacks == 0 && _noPendingCallbacks != null) { 233 if (_pendingCallbacks == 0 && _noPendingCallbacks != null) {
182 _noPendingCallbacks.complete(); 234 _noPendingCallbacks.complete();
183 _noPendingCallbacks = null; 235 _noPendingCallbacks = null;
184 } 236 }
185 } 237 }
186 }; 238 };
187 } 239 }
188 240
189 /// Returns a string representation of all errors registered on this schedule. 241 /// Returns a string representation of all errors registered on this schedule.
190 String errorString() { 242 String errorString() {
191 if (errors.isEmpty) return "The schedule had no errors."; 243 if (errors.isEmpty) return "The schedule had no errors.";
192 if (errors.length == 1) return errors.first.toString(); 244 if (errors.length == 1) return errors.first.toString();
193 var errorStrings = errors.map((e) => e.toString()).join("\n================" 245 var errorStrings = errors.map((e) => e.toString()).join("\n================"
194 "================================================================\n"); 246 "================================================================\n");
195 return "The schedule had ${errors.length} errors:\n$errorStrings"; 247 return "The schedule had ${errors.length} errors:\n$errorStrings";
196 } 248 }
197 249
250 /// Notifies the schedule that progress is being made on an asynchronous task.
251 /// This resets the timeout timer, and can be used in long-running tasks to
252 /// keep them from timing out.
253 void heartbeat() {
254 if (_timeoutTimer != null) _timeoutTimer.cancel();
255 if (_timeoutMs == null) {
256 _timeoutTimer = null;
257 } else {
258 _timeoutTimer = mock_clock.getTimer(_timeoutMs, _signalTimeout);
259 }
260 }
261
262 /// The callback to run when the timeout timer fires. Notifies the current
263 /// queue that a timeout has occurred.
264 void _signalTimeout(_) {
265 // Reset the timer so that we can detect timeouts in the onException and
266 // onComplete queues.
267 _timeoutTimer = null;
268
269 var error = new ScheduleError.from(this, "The schedule timed out after "
270 "${_timeoutMs}ms of inactivity.");
271
272 _pendingCallbacks = 0;
273 if (_noPendingCallbacks != null) {
274 var noPendingCallbacks = _noPendingCallbacks;
275 _noPendingCallbacks = null;
276 noPendingCallbacks.completeError(error);
277 } else {
278 currentQueue._signalTimeout(error);
279 }
280 }
281
198 /// Returns a [Future] that will complete once there are no pending 282 /// Returns a [Future] that will complete once there are no pending
199 /// out-of-band callbacks. 283 /// out-of-band callbacks.
200 Future _awaitNoPendingCallbacks() { 284 Future _awaitNoPendingCallbacks() {
201 if (_pendingCallbacks == 0) return new Future.immediate(null); 285 if (_pendingCallbacks == 0) return new Future.immediate(null);
202 if (_noPendingCallbacks == null) _noPendingCallbacks = new Completer(); 286 if (_noPendingCallbacks == null) _noPendingCallbacks = new Completer();
203 return _noPendingCallbacks.future; 287 return _noPendingCallbacks.future;
204 } 288 }
205 } 289 }
206 290
291 /// An enum of states for a [Schedule].
292 class ScheduleState {
293 /// The schedule can have tasks added to its queue, but is not yet running
294 /// them.
295 static const SET_UP = const ScheduleState._("SET_UP");
296
297 /// The schedule is actively running tasks. This includes running tasks in
298 /// [Schedule.onException] and [Schedule.onComplete].
299 static const RUNNING = const ScheduleState._("RUNNING");
300
301 /// The schedule has finished running all its tasks, either successfully or
302 /// with an error.
303 static const DONE = const ScheduleState._("DONE");
304
305 /// The name of the state.
306 final String name;
307
308 const ScheduleState._(this.name);
309
310 String toString() => name;
311 }
312
207 /// A queue of asynchronous tasks to execute in order. 313 /// A queue of asynchronous tasks to execute in order.
208 class TaskQueue { 314 class TaskQueue {
209 // TODO(nweiz): make this a read-only view when issue 8321 is fixed. 315 // TODO(nweiz): make this a read-only view when issue 8321 is fixed.
210 /// The tasks in the queue. 316 /// The tasks in the queue.
211 Collection<Task> get contents => _contents; 317 Collection<Task> get contents => _contents;
212 final _contents = new Queue<Task>(); 318 final _contents = new Queue<Task>();
213 319
214 /// The name of the queue, for debugging purposes. 320 /// The name of the queue, for debugging purposes.
215 final String name; 321 final String name;
216 322
217 /// The [Schedule] that created this queue. 323 /// The [Schedule] that created this queue.
218 final Schedule _schedule; 324 final Schedule _schedule;
219 325
220 /// An out-of-band error signaled by [_schedule]. If this is non-null, it 326 /// An out-of-band error signaled by [_schedule]. If this is non-null, it
221 /// indicates that the queue should stop as soon as possible and re-throw this 327 /// indicates that the queue should stop as soon as possible and re-throw this
222 /// error. 328 /// error.
223 ScheduleError _error; 329 ScheduleError _error;
224 330
331 /// The [SubstituteFuture] for the currently-running task in the queue, or
332 /// null if no task is currently running.
333 SubstituteFuture _taskFuture;
334
225 TaskQueue._(this.name, this._schedule); 335 TaskQueue._(this.name, this._schedule);
226 336
227 /// Schedules a task, [fn], to run asynchronously as part of this queue. Tasks 337 /// Schedules a task, [fn], to run asynchronously as part of this queue. Tasks
228 /// will be run in the order they're scheduled. In [fn] returns a [Future], 338 /// will be run in the order they're scheduled. In [fn] returns a [Future],
229 /// tasks after it won't be run until that [Future] completes. 339 /// tasks after it won't be run until that [Future] completes.
230 /// 340 ///
231 /// The return value will be completed once the scheduled task has finished 341 /// The return value will be completed once the scheduled task has finished
232 /// running. Its return value is the same as the return value of [fn], or the 342 /// running. Its return value is the same as the return value of [fn], or the
233 /// value it completes to if it's a [Future]. 343 /// value it completes to if it's a [Future].
234 /// 344 ///
235 /// If [description] is passed, it's used to describe the task for debugging 345 /// If [description] is passed, it's used to describe the task for debugging
236 /// purposes when an error occurs. 346 /// purposes when an error occurs.
237 Future schedule(fn(), [String description]) { 347 Future schedule(fn(), [String description]) {
238 var task = new Task(fn, this, description); 348 var task = new Task(fn, this, description);
239 _contents.add(task); 349 _contents.add(task);
240 return task.result; 350 return task.result;
241 } 351 }
242 352
243 /// Runs all the tasks in this queue in order. 353 /// Runs all the tasks in this queue in order.
244 Future _run() { 354 Future _run() {
245 _schedule._currentQueue = this; 355 _schedule._currentQueue = this;
356 _schedule.heartbeat();
246 return Future.forEach(_contents, (task) { 357 return Future.forEach(_contents, (task) {
247 _schedule._currentTask = task; 358 _schedule._currentTask = task;
248 if (_error != null) throw _error; 359 if (_error != null) throw _error;
249 return task.fn().catchError((e) { 360
361 _taskFuture = new SubstituteFuture(task.fn());
362 return _taskFuture.whenComplete(() {
363 _taskFuture = null;
364 _schedule.heartbeat();
365 }).catchError((e) {
250 if (_error != null) _schedule.errors.add(_error); 366 if (_error != null) _schedule.errors.add(_error);
251 throw new ScheduleError.from(_schedule, e, task: task); 367 throw new ScheduleError.from(_schedule, e);
252 }); 368 });
253 }).whenComplete(() { 369 }).whenComplete(() {
254 _schedule._currentTask = null; 370 _schedule._currentTask = null;
255 return _schedule._awaitNoPendingCallbacks(); 371 return _schedule._awaitNoPendingCallbacks();
256 }).then((_) { 372 }).then((_) {
373 _schedule.heartbeat();
257 if (_error != null) throw _error; 374 if (_error != null) throw _error;
258 }); 375 });
259 } 376 }
260 377
261 /// Signals that an out-of-band error has been detected and the queue should 378 /// Signals that an out-of-band error has been detected and the queue should
262 /// stop running as soon as possible. 379 /// stop running as soon as possible.
263 void _signalError(ScheduleError error) { 380 void _signalError(ScheduleError error) {
264 // If multiple errors are detected while a task is running, make sure the 381 // If multiple errors are detected while a task is running, make sure the
265 // earlier ones are recorded in the schedule. 382 // earlier ones are recorded in the schedule.
266 if (_error != null) _schedule.errors.add(_error); 383 if (_error != null) _schedule.errors.add(_error);
267 _error = error; 384 _error = error;
268 } 385 }
269 386
387 /// Notifies the queue that it has timed out and it needs to terminate
388 /// immediately with a timeout error.
389 void _signalTimeout(ScheduleError error) {
390 if (_taskFuture != null) {
391 // Catch errors coming off the old task future, in case it completes after
392 // timing out.
393 _taskFuture.substitute(new Future.immediateError(error)).catchError((e) {
394 _schedule._signalPostTimeoutError(e);
395 });
396 } else {
397 // This branch probably won't be reached, but it's conceivable that the
398 // event loop might get pumped when _taskFuture is null but we haven't yet
399 // called _awaitNoPendingCallbacks.
400 _signalError(error);
401 }
402 }
403
270 String toString() => name; 404 String toString() => name;
271 405
272 /// Returns a detailed representation of the queue as a tree of tasks. If 406 /// Returns a detailed representation of the queue as a tree of tasks. If
273 /// [highlight] is passed, that task is specially highlighted. 407 /// [highlight] is passed, that task is specially highlighted.
274 /// 408 ///
275 /// [highlight] must be a task in this queue. 409 /// [highlight] must be a task in this queue.
276 String generateTree([Task highlight]) { 410 String generateTree([Task highlight]) {
277 assert(highlight == null || highlight.queue == this); 411 assert(highlight == null || highlight.queue == this);
278 return _contents.map((task) { 412 return _contents.map((task) {
279 var lines = task.toString().split("\n"); 413 var lines = task.toString().split("\n");
280 var firstLine = task == highlight ? 414 var firstLine = task == highlight ?
281 "> ${lines.first}" : "* ${lines.first}"; 415 "> ${lines.first}" : "* ${lines.first}";
282 lines = new List.from(lines.skip(1).map((line) => "| $line")); 416 lines = new List.from(lines.skip(1).map((line) => "| $line"));
283 lines.insertRange(0, 1, firstLine); 417 lines.insertRange(0, 1, firstLine);
284 return lines.join("\n"); 418 return lines.join("\n");
285 }).join("\n"); 419 }).join("\n");
286 } 420 }
287 } 421 }
OLDNEW

Powered by Google App Engine
This is Rietveld 408576698